PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.14
ZIP AI – AI Website Builder & AI Agent v0.0.14
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / zipai / builder / get-site-context.php

get-site-context.php in ZIP AI – AI Website Builder & AI Agent 0.0.14, at inc/abilities/zipai/builder/get-site-context.php

427 lines 15.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Get Site Context — a read-only site snapshot for external AI clients.
4 *
5 * This is the counterpart to `zipai/import-html`. The client's own model
6 * writes the HTML. Without this snapshot the model writes blind. It knows
7 * nothing about the palette, the fonts, the existing pages or the brand of the
8 * site. So imported pages clash with the design. They also collide with
9 * existing slugs. One call returns the facts to author HTML that fits THIS
10 * site.
11 *
12 * The snapshot holds facts only. It gives no instructions. The palette is the
13 * LIVE WordPress global palette. It is origin-aware. The user palette from the
14 * Site Editor wins over the stock theme palette. This single read is correct
15 * with or without Spectra. The Style Guide colour sync pushes its role colours
16 * INTO these stores. These stores are the FSE user palette and the Astra
17 * slots. A plain block theme serves its own palette. Astra slots arrive as
18 * ast-global-color-N presets.
19 *
20 * The code filters out machine entries. These are `spectra-*` ramp tokens,
21 * `sg-*` sync mirrors and CSS keywords like currentColor. So the agent sees
22 * about a dozen semantic colours. It does not see the 100+ internal ones.
23 * Fonts are the resolved heading and body pairing from global styles. The code
24 * follows the preset and custom var chains to the family string. Pages arrive
25 * as slug and title. So links stay coherent and new slugs avoid collisions.
26 * Brand context comes from the stored `zip_ai_brand_context` option when the
27 * design system has captured one.
28 *
29 * @since 0.0.8
30 * @package zip-ai
31 */
32
33 namespace ZipAI\MCP\Classes\Abilities\Zipai\Builder;
34
35 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
36 use ZipAI\MCP\Classes\Core\Imported_Chrome;
37 use ZipAI\MCP\Classes\Core\Response;
38 use ZipAI\MCP\Classes\Core\Tool_Types;
39
40 defined( 'ABSPATH' ) || exit;
41
42 /**
43 * Ability: read the site identity + design system an HTML author must match.
44 */
45 class GetSiteContext extends Abstract_Ability {
46
47 /**
48 * Read-only by nature.
49 *
50 * @var bool
51 */
52 protected $is_destructive = false;
53
54 /**
55 * Configures the ability's id, label, description and metadata.
56 *
57 * @return void
58 */
59 public function configure() {
60 $this->id = 'zipai/get-site-context';
61 $this->label = 'Get Site Context';
62 $this->description = 'Read-only snapshot of this site for authoring HTML that matches it: title/tagline/language/URL, active theme, the live colour palette (palette: slug/name/hex, user customisations included), the resolved heading/body font pairing, existing pages (slug, title, front page) and stored brand context. Call this BEFORE writing HTML for zipai/import-html: match the returned palette and fonts, and pick a slug that does not collide with an existing page.';
63 // Read surface. Deliberately looser than the ingress floor (like
64 // run-rest-request): everything returned is design tokens and published
65 // page titles, and the `manage_options` MCP ingress dominates anyway.
66 $this->capability = 'edit_posts';
67 // Hidden from the AI chat catalog — the server reads site state itself
68 // over MCP (site-state fetcher); only external clients need this door,
69 // and the external MCP server advertises it explicitly.
70 $this->meta['visibility'] = 'internal';
71 }
72
73 /**
74 * Returns the tool-type classification for this ability.
75 *
76 * @return string One of the Tool_Types constants.
77 */
78 public function get_tool_type() {
79 return Tool_Types::READ;
80 }
81
82 /**
83 * Returns the JSON Schema for this ability's input arguments.
84 *
85 * @return array<string,mixed> JSON Schema describing accepted arguments.
86 */
87 public function get_input_schema() {
88 return array(
89 'type' => 'object',
90 'properties' => new \stdClass(),
91 'additionalProperties' => false,
92 );
93 }
94
95 /**
96 * Returns the JSON Schema for this ability's response.
97 *
98 * @return array<string,mixed> JSON Schema describing the response shape.
99 */
100 public function get_output_schema() {
101 return array(
102 'type' => 'object',
103 'properties' => array(
104 'success' => array( 'type' => 'boolean' ),
105 'message' => array( 'type' => 'string' ),
106 'data' => array(
107 'type' => 'object',
108 'properties' => array(
109 'site' => array(
110 'type' => 'object',
111 'properties' => array(
112 'title' => array( 'type' => 'string' ),
113 'tagline' => array( 'type' => 'string' ),
114 'language' => array( 'type' => 'string' ),
115 'url' => array( 'type' => 'string' ),
116 'logo_url' => array( 'type' => 'string' ),
117 ),
118 ),
119 'theme' => array(
120 'type' => 'object',
121 'properties' => array(
122 'name' => array( 'type' => 'string' ),
123 'is_block_theme' => array( 'type' => 'boolean' ),
124 'imported_chrome' => array( 'type' => 'boolean' ),
125 ),
126 ),
127 // Live global palette: [{ slug, name, hex }].
128 'palette' => array(
129 'type' => 'array',
130 'items' => array(
131 'type' => 'object',
132 'properties' => array(
133 'slug' => array( 'type' => 'string' ),
134 'name' => array( 'type' => 'string' ),
135 'hex' => array( 'type' => 'string' ),
136 ),
137 ),
138 ),
139 // Resolved font pairing: { heading?: string, body?: string }.
140 'fonts' => array(
141 'type' => 'object',
142 'properties' => array(
143 'heading' => array( 'type' => 'string' ),
144 'body' => array( 'type' => 'string' ),
145 ),
146 ),
147 'pages' => array(
148 'type' => 'array',
149 'items' => array(
150 'type' => 'object',
151 'properties' => array(
152 'slug' => array( 'type' => 'string' ),
153 'title' => array( 'type' => 'string' ),
154 'is_front' => array( 'type' => 'boolean' ),
155 ),
156 ),
157 ),
158 'brand_context' => array( 'type' => 'object' ),
159 ),
160 ),
161 ),
162 );
163 }
164
165 /**
166 * Assemble the snapshot.
167 *
168 * @param array<string,mixed> $input Validated input arguments (unused).
169 * @return array<string,mixed> Standardized success response.
170 */
171 public function execute( $input = array() ) {
172 $theme = wp_get_theme();
173
174 $site = array(
175 'title' => (string) get_bloginfo( 'name' ),
176 'tagline' => (string) get_bloginfo( 'description' ),
177 'language' => (string) get_bloginfo( 'language' ),
178 'url' => (string) home_url(),
179 );
180 $logo_mod = get_theme_mod( 'custom_logo' );
181 $logo_id = is_numeric( $logo_mod ) ? (int) $logo_mod : 0;
182 if ( $logo_id > 0 ) {
183 $logo_url = wp_get_attachment_image_url( $logo_id, 'full' );
184 if ( is_string( $logo_url ) && '' !== $logo_url ) {
185 $site['logo_url'] = $logo_url;
186 }
187 }
188
189 // Live global palette — one read that is correct with or without
190 // Spectra: the Style Guide's colour sync (v2) pushes its role colours
191 // INTO these stores, Astra's slots register as ast-global-color-N
192 // presets, plain block themes serve their own. Origin preference and
193 // machine-token filtering live in palette_from_node().
194 $raw_settings = wp_get_global_settings();
195 $global_settings = is_array( $raw_settings ) ? $raw_settings : array();
196 $palette = $this->palette_from_node( $this->array_node( $global_settings, 'color', 'palette' ) );
197
198 // The resolved heading/body font pairing from global styles — what the
199 // site actually renders with, not the theme's whole font library.
200 $styles = wp_get_global_styles();
201 $fonts = array();
202 $root_typo = $this->array_node( $styles, 'typography' );
203 $heading_typo = $this->array_node( $styles, 'elements', 'heading', 'typography' );
204 $body = $this->resolve_font_var(
205 isset( $root_typo['fontFamily'] ) && is_string( $root_typo['fontFamily'] )
206 ? $root_typo['fontFamily']
207 : '',
208 $global_settings
209 );
210 $heading = $this->resolve_font_var(
211 isset( $heading_typo['fontFamily'] ) && is_string( $heading_typo['fontFamily'] )
212 ? $heading_typo['fontFamily']
213 : '',
214 $global_settings
215 );
216 if ( '' !== $heading ) {
217 $fonts['heading'] = $heading;
218 }
219 if ( '' !== $body ) {
220 $fonts['body'] = $body;
221 }
222
223 $pages = array();
224 // `page_on_front` can hold a stale id while the site shows latest
225 // posts — only a `show_on_front = page` site has a front PAGE at all.
226 $front_raw = 'page' === get_option( 'show_on_front' ) ? get_option( 'page_on_front' ) : 0;
227 $front_id = is_numeric( $front_raw ) ? (int) $front_raw : 0;
228 $page_rows = get_pages( array( 'number' => 100 ) );
229 foreach ( is_array( $page_rows ) ? $page_rows : array() as $page ) {
230 $pages[] = array(
231 'slug' => (string) $page->post_name,
232 'title' => (string) $page->post_title,
233 'is_front' => $page->ID === $front_id,
234 );
235 }
236
237 // Stored by the design system; may be a JSON string or an array
238 // (same tolerant read as the chat panel's ZIPAI_CONFIG.brandContext).
239 $brand_context = get_option( 'zip_ai_brand_context', array() );
240 if ( is_string( $brand_context ) ) {
241 $brand_context = json_decode( $brand_context, true );
242 }
243 if ( ! is_array( $brand_context ) ) {
244 $brand_context = array();
245 }
246
247 $data = array(
248 'site' => $site,
249 'theme' => array(
250 'name' => (string) $theme->get( 'Name' ),
251 'is_block_theme' => (bool) $theme->is_block_theme(),
252 // True when the zip-ai canvas renders imported header/footer
253 // site-wide — the site's visible chrome is the IMPORTED design,
254 // whatever the theme says.
255 'imported_chrome' => Imported_Chrome::is_takeover_active(),
256 ),
257 'pages' => $pages,
258 );
259 if ( ! empty( $palette ) ) {
260 $data['palette'] = $palette;
261 }
262 if ( ! empty( $fonts ) ) {
263 $data['fonts'] = $fonts;
264 }
265 if ( ! empty( $brand_context ) ) {
266 $data['brand_context'] = $brand_context;
267 }
268
269 $message = sprintf(
270 'Site context for "%s": %d pages, %s.',
271 $site['title'],
272 count( $pages ),
273 ! empty( $palette )
274 ? 'a palette of ' . count( $palette ) . ' colours'
275 : 'no global palette (rely on brand_context or ask the user)'
276 );
277
278 return Response::success( $message, $data );
279 }
280
281 /**
282 * Resolve a global-styles font value to the actual family string.
283 *
284 * Themes rarely store the family literally — Spectra One chains
285 * `var(--wp--custom--font-family--body)` → custom['font-family']['body']
286 * → `var(--wp--preset--font-family--inter)` → the preset's fontFamily
287 * (measured live). Follows both var shapes against the settings tree,
288 * trying each custom path segment as-written and camelCased (WP kebab-cases
289 * custom keys when minting the var, so `fontFamily` and `font-family`
290 * produce the SAME var name), bounded to a few hops; anything unresolvable
291 * returns '' rather than handing the agent a CSS var it cannot render.
292 *
293 * @param string $value Font value from global styles.
294 * @param array<mixed> $settings Full wp_get_global_settings() tree.
295 * @return string Family string, or '' when absent/unresolvable.
296 */
297 private function resolve_font_var( string $value, array $settings ): string {
298 for ( $hop = 0; $hop < 4; $hop++ ) {
299 $value = trim( $value );
300 if ( '' === $value ) {
301 return '';
302 }
303 if ( ! preg_match( '/^var\(\s*--wp--(preset|custom)--(.+?)\s*\)$/', $value, $m ) ) {
304 return $value;
305 }
306 if ( 'preset' === $m[1] ) {
307 // --wp--preset--font-family--{slug} → typography.fontFamilies.
308 if ( ! preg_match( '/^font-family--([a-z0-9-]+)$/i', $m[2], $pm ) ) {
309 return '';
310 }
311 $value = $this->preset_family( $pm[1], $settings );
312 continue;
313 }
314 // --wp--custom--{a--b--…} → settings['custom'][a][b]…
315 $node = $this->array_node( $settings, 'custom' );
316 foreach ( explode( '--', $m[2] ) as $segment ) {
317 if ( ! is_array( $node ) ) {
318 return '';
319 }
320 if ( ! isset( $node[ $segment ] ) ) {
321 // WP kebab-cases custom keys when minting the CSS var, so a
322 // camelCase theme.json key (fontFamily) produces the SAME
323 // var name as a hyphenated one — try that spelling too.
324 $segment = lcfirst( str_replace( ' ', '', ucwords( str_replace( '-', ' ', $segment ) ) ) );
325 if ( ! isset( $node[ $segment ] ) ) {
326 return '';
327 }
328 }
329 $node = $node[ $segment ];
330 }
331 if ( ! is_string( $node ) ) {
332 return '';
333 }
334 $value = $node;
335 }
336 return '';
337 }
338
339 /**
340 * Collect usable colours from a global-settings palette node.
341 *
342 * Origin-aware: the Site Editor's user palette ('custom') wins over the
343 * theme's stock one, and core's 'default' grayscale is ignored. An origin
344 * only wins when it yields at least one USABLE colour after filtering —
345 * a user layer holding only machine tokens must fall through to the
346 * theme layer, not blank the palette. Machine entries are dropped:
347 * `spectra-*` ramp tokens and `sg-*` sync mirrors duplicate the semantic
348 * head (measured live: 115 raw entries, ~19 semantic), and CSS keywords
349 * (currentColor/inherit/transparent) are not colours an author can match.
350 *
351 * @param array<mixed> $palette_node The settings' color.palette node.
352 * @return array<int,array{slug: string, name: string, hex: string}> Capped at 30.
353 */
354 private function palette_from_node( array $palette_node ): array {
355 foreach ( array( 'custom', 'theme' ) as $origin ) {
356 if ( empty( $palette_node[ $origin ] ) || ! is_array( $palette_node[ $origin ] ) ) {
357 continue;
358 }
359 $collected = array();
360 foreach ( $palette_node[ $origin ] as $entry ) {
361 if ( ! is_array( $entry ) || empty( $entry['color'] ) || ! is_string( $entry['color'] ) ) {
362 continue;
363 }
364 $slug = isset( $entry['slug'] ) && is_string( $entry['slug'] ) ? $entry['slug'] : '';
365 if ( 0 === strpos( $slug, 'spectra-' ) || 0 === strpos( $slug, 'sg-' ) ) {
366 continue;
367 }
368 if ( ! preg_match( '/^(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/', $entry['color'] ) ) {
369 continue;
370 }
371 $collected[] = array(
372 'slug' => $slug,
373 'name' => isset( $entry['name'] ) && is_string( $entry['name'] ) ? $entry['name'] : '',
374 'hex' => $entry['color'],
375 );
376 }
377 if ( ! empty( $collected ) ) {
378 return array_slice( $collected, 0, 30 );
379 }
380 }
381 return array();
382 }
383
384 /**
385 * Walk nested array offsets, returning array() the moment a level is
386 * missing or not an array — wp_get_global_settings()/wp_get_global_styles()
387 * hand back untyped trees.
388 *
389 * @param mixed $tree Root value.
390 * @param string ...$path Offsets to descend.
391 * @return array<mixed>
392 */
393 private function array_node( $tree, string ...$path ): array {
394 foreach ( $path as $key ) {
395 if ( ! is_array( $tree ) || ! isset( $tree[ $key ] ) ) {
396 return array();
397 }
398 $tree = $tree[ $key ];
399 }
400 return is_array( $tree ) ? $tree : array();
401 }
402
403 /**
404 * Look up a font-family preset by slug across origins (user wins).
405 *
406 * @param string $slug Preset slug.
407 * @param array<mixed> $settings Full wp_get_global_settings() tree.
408 * @return string The preset's fontFamily, or '' when not found.
409 */
410 private function preset_family( string $slug, array $settings ): string {
411 $families = $this->array_node( $settings, 'typography', 'fontFamilies' );
412 foreach ( array( 'custom', 'theme', 'default' ) as $origin ) {
413 if ( empty( $families[ $origin ] ) || ! is_array( $families[ $origin ] ) ) {
414 continue;
415 }
416 foreach ( $families[ $origin ] as $family ) {
417 if ( is_array( $family )
418 && isset( $family['slug'] ) && $family['slug'] === $slug
419 && isset( $family['fontFamily'] ) && is_string( $family['fontFamily'] ) ) {
420 return $family['fontFamily'];
421 }
422 }
423 }
424 return '';
425 }
426 }
427