Skip to content

Commit c87eab4

Browse files
ifahimrezaclaude
andcommitted
feat: Saddle_Lint_Style_Accessor companion interface + Gutenberg impl
Closed-loop foundation (#23): the deeper quality rules need design facts the base lint accessor doesn't expose, and adding methods to Saddle_Lint_Accessor would fatal any older Pro accessor against a newer free. So the new facts live on an ADDITIVE companion interface; rules feature-detect with instanceof and skip silently when an accessor hasn't caught up. - interface-saddle-lint-style-accessor.php: border_radius, gap, font_size, image_alt, heading_level, global_preset_ref, variable_refs, design_brief, and the computed_style seam the render pillar fills later. Same contract as the base interface: resolve what you can, return null over guessing. - Gutenberg accessor implements it: corner radii serialized clockwise for identity comparison, blockGap axes joined, font-size preset slugs resolved through theme.json (mirrors the palette resolver), core/image alt read off the saved <img> (covers stay decorative -> null), var(--...) plus internal var:preset|...| refs collected and normalized. - tests/style-accessor-test.php (19 tests): every getter both ways (populated + null), plus the versioning guarantee the split exists for - a base-only legacy accessor runs through Saddle_Lint::run() with zero fatals. Full suite 263 green; phpcs clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 628a083 commit c87eab4

4 files changed

Lines changed: 606 additions & 1 deletion

File tree

includes/lint/class-saddle-lint-gutenberg-accessor.php

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
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
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
/**
3+
* The additive companion to Saddle_Lint_Accessor.
4+
*
5+
* @package Saddle
6+
*/
7+
8+
defined( 'ABSPATH' ) || exit;
9+
10+
/**
11+
* Extended design facts for the deeper quality rules (closed-loop scope,
12+
* https://github.com/plugpressco/saddle/issues/23).
13+
*
14+
* A SEPARATE interface, on purpose: adding methods to Saddle_Lint_Accessor
15+
* would fatal any older accessor implementation the moment free Saddle
16+
* updates. Instead, accessors additionally implement this companion, and
17+
* rules feature-detect with `$accessor instanceof Saddle_Lint_Style_Accessor`
18+
* — an accessor that hasn't caught up simply makes those rules skip.
19+
*
20+
* The base interface's contract carries over unchanged: methods take a raw
21+
* block array from Saddle_Tree::parse(), accessors resolve indirection where
22+
* they can (preset slug → theme.json value, var(--gcid-…) → global palette),
23+
* and every fact that is unknown or not applicable returns null — rules must
24+
* skip, never guess.
25+
*/
26+
interface Saddle_Lint_Style_Accessor {
27+
28+
/**
29+
* The node's own corner radius.
30+
*
31+
* Composite per-corner values are serialized deterministically (clockwise
32+
* from top-left, space-joined) so two nodes with identical corners compare
33+
* equal — the monotony rules only ever test identity, never parse.
34+
*
35+
* @param array $node Raw block array.
36+
* @return string|null CSS radius string, or null when the node sets none.
37+
*/
38+
public function border_radius( array $node );
39+
40+
/**
41+
* The node's own gap between children (row/column gap).
42+
*
43+
* @param array $node Raw block array.
44+
* @return string|null CSS gap string ("row col" when they differ), or null
45+
* when the node sets none / has no layout gap.
46+
*/
47+
public function gap( array $node );
48+
49+
/**
50+
* The node's own font size, resolved as far as possible.
51+
*
52+
* @param array $node Raw block array.
53+
* @return string|null CSS size (preset slugs resolved to their value), or
54+
* null when unset/unresolvable.
55+
*/
56+
public function font_size( array $node );
57+
58+
/**
59+
* The alt text of an image-content node.
60+
*
61+
* Only content images count — decorative background media (covers,
62+
* section backgrounds) returns null, so the missing-alt rule never nags
63+
* about media that is correctly decorative. Alt bound to dynamic content
64+
* counts as satisfied and returns a non-empty sentinel.
65+
*
66+
* @param array $node Raw block array.
67+
* @return string|null null when the node is not a content image;
68+
* '' when it is one and the alt is missing/empty
69+
* (that IS the lint); the alt text otherwise.
70+
*/
71+
public function image_alt( array $node );
72+
73+
/**
74+
* The heading level of a heading node.
75+
*
76+
* @param array $node Raw block array.
77+
* @return int|null 1–6, or null when the node is not a heading.
78+
*/
79+
public function heading_level( array $node );
80+
81+
/**
82+
* The id of the user-editable global preset this node is bound to.
83+
*
84+
* "Global preset" means a site-wide, owner-editable style entity (Divi's
85+
* global presets); registered code-level block styles do not count.
86+
*
87+
* @param array $node Raw block array.
88+
* @return string|null Preset id, or null when unbound / no such concept.
89+
*/
90+
public function global_preset_ref( array $node );
91+
92+
/**
93+
* The design-token variables this node's own attrs reference.
94+
*
95+
* @param array $node Raw block array.
96+
* @return string[] Unique variable names (e.g. '--wp--preset--color--primary',
97+
* '--gcid-abc123'), document-order first occurrence.
98+
* Empty array when none.
99+
*/
100+
public function variable_refs( array $node );
101+
102+
/**
103+
* The page's committed design brief merged over derived site constraints.
104+
*
105+
* Shape (all keys optional): { palette: string[], accent_count: int,
106+
* radius_scale: string[], spacing_scale: string[], type_scale: string[],
107+
* layout_concept: string, palette_closed: bool }. The brief-conformance
108+
* rule fires ONLY when this returns non-null — briefless pages get zero
109+
* brief violations.
110+
*
111+
* @return array|null The brief, or null when none is committed/derivable.
112+
*/
113+
public function design_brief();
114+
115+
/**
116+
* Render-backed computed style for a node, when a render pillar can
117+
* supply one (box model, resolved colors as painted). The seam the
118+
* render engine fills; tree-only accessors return null and rules fall
119+
* back to persisted-attr facts.
120+
*
121+
* @param array $node Raw block array.
122+
* @return array|null Computed style map, or null when unavailable.
123+
*/
124+
public function computed_style( array $node );
125+
}

saddle.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
require_once SADDLE_DIR . 'includes/class-saddle-blocks-schema.php';
3737
require_once SADDLE_DIR . 'includes/class-saddle-blocks-echo.php';
3838
require_once SADDLE_DIR . 'includes/lint/interface-saddle-lint-accessor.php';
39+
require_once SADDLE_DIR . 'includes/lint/interface-saddle-lint-style-accessor.php';
3940
require_once SADDLE_DIR . 'includes/lint/class-saddle-lint.php';
4041
require_once SADDLE_DIR . 'includes/lint/class-saddle-lint-rule.php';
4142
require_once SADDLE_DIR . 'includes/lint/class-saddle-lint-color.php';

0 commit comments

Comments
 (0)