Skip to content

Commit 5f20242

Browse files
ifahimrezaclaude
andcommitted
feat: Agent Eyes — render engine + saddle/render-node
Pillar 1 of the closed-loop scope (#24): the agent gets to SEE the effective result of what it built, in-process, with no third-party custody and no browser. - interface-saddle-render-accessor.php: the one builder surface — effective_styles(node), render_node_html(post, tree, address), render_fidelity(). Mirrors the lint accessor split exactly. - class-saddle-render.php: the generic engine. One node in detail, or a whole page as a bounded section outline (address, type, child count, first text, key styles) — the agent drills in by address instead of paying for every node's HTML. Rendered HTML is stripped of script/style/ comments and hard-capped at 2KB with a visible [truncated] marker. - class-saddle-render-gutenberg-accessor.php: native impl. Effective styles COMPOSE the Gutenberg lint accessor's resolution (one resolver — lint and render can never disagree about what a preset slug means); HTML renders through core's render_block(), fidelity labelled 'in-process'. - abilities/render.php: saddle/render-node (read tier). Native pages get the Gutenberg accessor; builder pages resolve through the new saddle_render_accessor filter — same one-tool-surface pattern as lint, ready for Pro's Divi driver (P1). tests/render-test.php (10 tests): style resolution, include selection, HTML cap + script stripping, unknown-address error, bounded outline, lone-wrapper outlining, builder refusal + filter resolution, tier meta. Free suite 281 green; Pro suite 119 green; phpcs clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6940d04 commit 5f20242

6 files changed

Lines changed: 715 additions & 0 deletions

File tree

includes/abilities/render.php

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
/**
3+
* The render abilities — the agent's eyes.
4+
*
5+
* saddle/render-node shows an agent the EFFECTIVE result of what it built:
6+
* persisted attrs resolved through presets/globals into a normalized style
7+
* map, plus rendered HTML. Native pages use the Gutenberg accessor; builder
8+
* pages resolve theirs through the `saddle_render_accessor` filter — the
9+
* same one-tool-surface pattern as lint (closed-loop scope,
10+
* https://github.com/plugpressco/saddle/issues/24).
11+
*
12+
* @package Saddle
13+
*/
14+
15+
defined( 'ABSPATH' ) || exit;
16+
17+
/**
18+
* Register the render abilities. Hooked to `wp_abilities_api_init`.
19+
*/
20+
function saddle_register_render_abilities() {
21+
22+
wp_register_ability(
23+
'saddle/render-node',
24+
array(
25+
'label' => __( 'Render a node', 'saddle' ),
26+
'description' => __( 'Shows what a page node actually looks like from its SAVED state: effective styles (colors, padding, font size, radius — presets and global tokens resolved to real values) and its rendered HTML. Read-only. Call with an "address" from get-blocks/divi-get-page to inspect one node after building or editing it; call without an address for a whole-page section outline (address, type, first text, key styles per section) and then drill into sections by address. Use it as your eyes: build, render, judge, fix.', 'saddle' ),
27+
'category' => 'saddle',
28+
'input_schema' => array(
29+
'type' => 'object',
30+
'required' => array( 'post_id' ),
31+
'properties' => array(
32+
'post_id' => array(
33+
'type' => 'integer',
34+
'description' => __( 'The post or page to look at.', 'saddle' ),
35+
),
36+
'address' => array(
37+
'type' => 'string',
38+
'description' => __( 'Dot address of one node (e.g. "0.1.0"). Omit for the whole-page outline.', 'saddle' ),
39+
),
40+
'include' => array(
41+
'type' => 'array',
42+
'items' => array(
43+
'type' => 'string',
44+
'enum' => array( 'styles', 'html' ),
45+
),
46+
'description' => __( 'Artifacts to include for a node (default: both).', 'saddle' ),
47+
),
48+
),
49+
),
50+
'execute_callback' => array( 'Saddle_Render_Abilities', 'render_node' ),
51+
'permission_callback' => Saddle_Capabilities::permission( 'read', 'read', 'render-node' ),
52+
'meta' => saddle_ability_meta( true, false, true, 'read' ),
53+
)
54+
);
55+
}
56+
57+
/**
58+
* Execute callbacks for the render abilities.
59+
*/
60+
class Saddle_Render_Abilities {
61+
62+
/**
63+
* saddle/render-node.
64+
*
65+
* @param array $input Ability input.
66+
* @return array|WP_Error
67+
*/
68+
public static function render_node( $input = null ) {
69+
$input = is_array( $input ) ? $input : array();
70+
$post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 );
71+
if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
72+
return new WP_Error( 'saddle_not_found', __( 'No post or page with that ID.', 'saddle' ), array( 'status' => 404 ) );
73+
}
74+
if ( ! current_user_can( 'read_post', $post->ID ) ) {
75+
return new WP_Error( 'saddle_forbidden', __( 'You cannot read this post.', 'saddle' ), array( 'status' => 403 ) );
76+
}
77+
78+
$accessor = self::accessor_for( $post );
79+
if ( is_wp_error( $accessor ) ) {
80+
return $accessor;
81+
}
82+
83+
$tree = Saddle_Tree::parse( $post->post_content );
84+
$builder = Saddle_Abilities::builder_signature( $post );
85+
$address = isset( $input['address'] ) ? trim( (string) $input['address'] ) : '';
86+
87+
if ( '' === $address ) {
88+
$outline = Saddle_Render::outline( $tree, $accessor );
89+
return array(
90+
'id' => $post->ID,
91+
'builder' => null === $builder ? 'native' : $builder,
92+
'outline' => $outline,
93+
'count' => count( $outline ),
94+
'note' => __( 'Top-level sections only. Drill into one with the same call plus its "address". Styles shown are the persisted effective values, not a browser render.', 'saddle' ),
95+
);
96+
}
97+
98+
$include = self::include_list( $input );
99+
$view = Saddle_Render::node( $post, $tree, $address, $accessor, $include );
100+
if ( is_wp_error( $view ) ) {
101+
return $view;
102+
}
103+
104+
return array_merge(
105+
array( 'id' => $post->ID ),
106+
$view,
107+
array(
108+
'note' => __( 'Effective persisted styles with presets/tokens resolved — what the saved state means, not a browser screenshot.', 'saddle' ),
109+
)
110+
);
111+
}
112+
113+
/**
114+
* Resolve the render accessor for a post: Gutenberg for native pages,
115+
* the `saddle_render_accessor` filter for builder pages.
116+
*
117+
* @param WP_Post $post The post.
118+
* @return Saddle_Render_Accessor|WP_Error
119+
*/
120+
private static function accessor_for( WP_Post $post ) {
121+
$builder = Saddle_Abilities::builder_signature( $post );
122+
$accessor = null === $builder ? new Saddle_Render_Gutenberg_Accessor() : null;
123+
124+
/**
125+
* Filter the render accessor for a page.
126+
*
127+
* Builder integrations (Saddle Pro's Divi driver, Elementor/Bricks
128+
* later) return their Saddle_Render_Accessor implementation when
129+
* they own $builder. Null means the page cannot be rendered here.
130+
*
131+
* @param Saddle_Render_Accessor|null $accessor Accessor (Gutenberg's for native pages).
132+
* @param string|null $builder Detected builder, null = native.
133+
* @param WP_Post $post The post.
134+
*/
135+
$accessor = apply_filters( 'saddle_render_accessor', $accessor, $builder, $post );
136+
137+
if ( ! $accessor instanceof Saddle_Render_Accessor ) {
138+
return new WP_Error(
139+
'saddle_render_unsupported',
140+
sprintf(
141+
/* translators: 1: post ID, 2: builder name. */
142+
__( 'Post #%1$d is built with %2$s, and no render accessor for that builder is installed. Divi 5 pages need Saddle Pro.', 'saddle' ),
143+
$post->ID,
144+
(string) $builder
145+
),
146+
array( 'status' => 409 )
147+
);
148+
}
149+
return $accessor;
150+
}
151+
152+
/**
153+
* The validated include list, defaulting to both artifacts.
154+
*
155+
* @param array $input Ability input.
156+
* @return string[]
157+
*/
158+
private static function include_list( array $input ) {
159+
$include = isset( $input['include'] ) && is_array( $input['include'] ) ? $input['include'] : array();
160+
$include = array_values( array_intersect( array( 'styles', 'html' ), array_map( 'strval', $include ) ) );
161+
return $include ? $include : array( 'styles', 'html' );
162+
}
163+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
/**
3+
* The Gutenberg implementation of the render accessor.
4+
*
5+
* @package Saddle
6+
*/
7+
8+
defined( 'ABSPATH' ) || exit;
9+
10+
/**
11+
* Native blocks render in-process through core's own pipeline, and effective
12+
* styles come from the SAME resolution the lint accessor already does —
13+
* composition, not a second resolver, so lint and render can never disagree
14+
* about what a preset slug means.
15+
*/
16+
class Saddle_Render_Gutenberg_Accessor implements Saddle_Render_Accessor {
17+
18+
/**
19+
* The lint accessor doing the actual attr resolution.
20+
*
21+
* @var Saddle_Lint_Gutenberg_Accessor
22+
*/
23+
private $facts;
24+
25+
public function __construct() {
26+
$this->facts = new Saddle_Lint_Gutenberg_Accessor();
27+
}
28+
29+
/**
30+
* Effective styles assembled from the lint accessor's resolved facts.
31+
* Keys the node doesn't style are omitted — an empty map means "this
32+
* node inherits everything".
33+
*
34+
* @param array $node Raw block array.
35+
* @return array
36+
*/
37+
public function effective_styles( array $node ) {
38+
$styles = array(
39+
'background' => $this->facts->background_color( $node ),
40+
'color' => $this->facts->text_color( $node ),
41+
'padding' => $this->facts->padding( $node ),
42+
'textAlign' => $this->facts->alignment( $node ),
43+
'fontSize' => $this->facts->font_size( $node ),
44+
'borderRadius' => $this->facts->border_radius( $node ),
45+
'gap' => $this->facts->gap( $node ),
46+
);
47+
$styles = array_filter(
48+
$styles,
49+
static function ( $value ) {
50+
return null !== $value;
51+
}
52+
);
53+
54+
if ( $this->facts->is_button( $node ) ) {
55+
$styles['button_filled'] = $this->facts->button_is_filled( $node );
56+
}
57+
return $styles;
58+
}
59+
60+
/**
61+
* Render the node through core's own block renderer. In-process: block
62+
* markup and dynamic blocks are real, theme stylesheets are not applied.
63+
*
64+
* @param WP_Post $post The post.
65+
* @param array $tree Parsed tree.
66+
* @param string $address Dot address.
67+
* @return string|WP_Error
68+
*/
69+
public function render_node_html( WP_Post $post, array $tree, $address ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- Fixed accessor signature.
70+
$block = Saddle_Tree::get( $tree, (string) $address );
71+
if ( ! $block ) {
72+
return new WP_Error( 'saddle_render_no_node', __( 'No node at that address.', 'saddle' ) );
73+
}
74+
return render_block( $block );
75+
}
76+
77+
/**
78+
* In-process: rendered inside this request, theme CSS not applied.
79+
*
80+
* @return string
81+
*/
82+
public function render_fidelity() {
83+
return 'in-process';
84+
}
85+
}

0 commit comments

Comments
 (0)