| 1 |
<?php |
| 2 |
/** |
| 3 |
* Site Blueprint — the plan the site was built from, stored WITH the site. |
| 4 |
* |
| 5 |
* The builder plans a whole website once: its sitemap, style, design tokens, |
| 6 |
* and the exact header/footer bytes every page wears. That plan lived only in |
| 7 |
* the build session, so a page added later — days later, from a fresh session — |
| 8 |
* had nothing to build against and re-derived its own design. Same site, two |
| 9 |
* looks. Storing the plan here is what lets "add a page" and vibe-editing's |
| 10 |
* "add a section" build into the family the site already has. |
| 11 |
* |
| 12 |
* The plan is reached ONLY through two brain-only abilities, `zipai/get-site-plan` |
| 13 |
* and `zipai/update-site-plan` (visibility `internal`, so the AI chat catalog never |
| 14 |
* lists them). It is deliberately NOT exposed to REST: a plan is ~200 KB, and |
| 15 |
* `/wp/v2/settings` serves every registered option whole, ignoring `_fields` when |
| 16 |
* dispatched through `rest_do_request` — so a plan in that collection turned every |
| 17 |
* settings read into a truncated response, the model's reads for a site title |
| 18 |
* included. `zipai/run-wp-cli` is no door either: it caps a command at 4,096 bytes. |
| 19 |
* |
| 20 |
* One slice IS printed: `rootAttrs`, the lever row (`data-style`, `data-motion`, …) |
| 21 |
* the site's JS and root-headed CSS read. `zipai/update-site-plan` copies it into the |
| 22 |
* small autoloaded `zip-ai-site-html-attrs` option; `language_attributes` prints it. |
| 23 |
* |
| 24 |
* @since 0.0.9 |
| 25 |
* @package zip-ai |
| 26 |
*/ |
| 27 |
|
| 28 |
namespace ZipAI\MCP\Classes\Core; |
| 29 |
|
| 30 |
if ( ! defined( 'ABSPATH' ) ) { |
| 31 |
exit; |
| 32 |
} |
| 33 |
|
| 34 |
/** |
| 35 |
* Registers the site-blueprint option — see the file header. |
| 36 |
*/ |
| 37 |
class Site_Blueprint { |
| 38 |
|
| 39 |
/** |
| 40 |
* The stored plan, as the JSON the builder produced. |
| 41 |
* |
| 42 |
* @var string |
| 43 |
*/ |
| 44 |
const OPTION_KEY = 'zip-ai-site-blueprint'; |
| 45 |
|
| 46 |
/** |
| 47 |
* The plan's `rootAttrs`, `name => value`, printed on `<html>` as `data-<name>="<value>"`. |
| 48 |
* |
| 49 |
* @var string |
| 50 |
*/ |
| 51 |
const HTML_ATTRS_OPTION_KEY = 'zip-ai-site-html-attrs'; |
| 52 |
|
| 53 |
/** |
| 54 |
* Hook registration. |
| 55 |
*/ |
| 56 |
public static function init(): void { |
| 57 |
add_action( 'init', array( __CLASS__, 'register_contract' ) ); |
| 58 |
add_filter( 'wp_default_autoload_value', array( __CLASS__, 'never_autoload' ), 10, 2 ); |
| 59 |
add_filter( 'language_attributes', array( __CLASS__, 'add_html_attrs' ) ); |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Register the option for its type and default only — never for REST. |
| 64 |
* |
| 65 |
* No sanitize_callback: the value is a JSON document, and WP's string |
| 66 |
* sanitizers would corrupt it. It is written only by the authenticated |
| 67 |
* builder over MCP and is never echoed into a page; its `rootAttrs` are |
| 68 |
* copied to HTML_ATTRS_OPTION_KEY, the one option that IS printed on `<html>`. |
| 69 |
* |
| 70 |
* The group is the plugin's own, which no admin form posts: wp-admin/options.php |
| 71 |
* writes null to every option of the posted group the form did not send. |
| 72 |
*/ |
| 73 |
public static function register_contract(): void { |
| 74 |
register_setting( |
| 75 |
'zip_ai', |
| 76 |
self::OPTION_KEY, |
| 77 |
array( |
| 78 |
'type' => 'string', |
| 79 |
'show_in_rest' => false, |
| 80 |
'default' => '', |
| 81 |
) |
| 82 |
); |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Keep the blueprint out of the autoloaded set. |
| 87 |
* |
| 88 |
* Core only sizes an option off automatically above 150,000 bytes |
| 89 |
* (`wp_max_autoloaded_option_size`) and a blueprint measures under that, so |
| 90 |
* it would autoload by default. A filter rather than a one-time |
| 91 |
* `add_option(..., false)`: the existence check that would need loads the |
| 92 |
* whole payload on every request — the exact cost being avoided. |
| 93 |
* |
| 94 |
* @param bool|null $autoload The autoload value core is about to use. |
| 95 |
* @param string $option The option being written. |
| 96 |
* @return bool|null False for the blueprint, untouched for everything else. |
| 97 |
*/ |
| 98 |
public static function never_autoload( $autoload, $option ) { |
| 99 |
return self::OPTION_KEY === $option ? false : $autoload; |
| 100 |
} |
| 101 |
|
| 102 |
/** |
| 103 |
* The printable entries of a `rootAttrs` map (lowercase name, string value) — applied |
| 104 |
* on write and again on print. |
| 105 |
* |
| 106 |
* @param mixed $attrs The plan's `rootAttrs`, or the stored option value. |
| 107 |
* @return array<string,string> |
| 108 |
*/ |
| 109 |
public static function html_attrs_from( $attrs ): array { |
| 110 |
if ( ! is_array( $attrs ) ) { |
| 111 |
return array(); |
| 112 |
} |
| 113 |
$row = array(); |
| 114 |
foreach ( $attrs as $name => $value ) { |
| 115 |
if ( is_string( $name ) && is_string( $value ) && preg_match( '/^[a-z][a-z0-9-]*$/D', $name ) ) { |
| 116 |
$row[ $name ] = $value; |
| 117 |
} |
| 118 |
} |
| 119 |
return $row; |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* `language_attributes` filter: append the row to core's `lang`/`dir` output. |
| 124 |
* |
| 125 |
* @param string $output The attributes core assembled. |
| 126 |
* @return string |
| 127 |
*/ |
| 128 |
public static function add_html_attrs( $output ) { |
| 129 |
if ( is_admin() ) { |
| 130 |
return $output; |
| 131 |
} |
| 132 |
foreach ( self::html_attrs_from( get_option( self::HTML_ATTRS_OPTION_KEY ) ) as $name => $value ) { |
| 133 |
$output .= ' data-' . $name . '="' . esc_attr( $value ) . '"'; |
| 134 |
} |
| 135 |
return $output; |
| 136 |
} |
| 137 |
} |
| 138 |
|