| 1 |
<?php |
| 2 |
|
| 3 |
namespace Better_Payment\Lite\AI\Schema; |
| 4 |
|
| 5 |
use Better_Payment\Lite\Campaign\Elements\ElementRegistry; |
| 6 |
|
| 7 |
if ( ! defined( 'ABSPATH' ) ) { |
| 8 |
exit; |
| 9 |
} |
| 10 |
|
| 11 |
/** |
| 12 |
* Canonical, machine-readable description of a Better Payment campaign. |
| 13 |
* |
| 14 |
* This is the single source of truth the AI layer uses to (a) tell a model what |
| 15 |
* it is allowed to produce and (b) validate what a model returns. It never |
| 16 |
* hardcodes the element list — everything is derived live from |
| 17 |
* {@see ElementRegistry::get_all()} so the AI schema can never drift from the |
| 18 |
* builder's real capabilities. |
| 19 |
* |
| 20 |
* A campaign is structured JSON: |
| 21 |
* { |
| 22 |
* "layout": "1-column" | "2-column" | "3-column", |
| 23 |
* "columns": [ |
| 24 |
* { "id": string, "label": string, "width": "NN%", |
| 25 |
* "elements": [ { "id": string, "type": <element type>, "settings": { ... } } ] } |
| 26 |
* ] |
| 27 |
* } |
| 28 |
* plus flat campaign meta (goal amount, colours, donation amounts, ...). |
| 29 |
* |
| 30 |
* @see \Better_Payment\Lite\AI\Operations\OperationValidator Uses these allowlists. |
| 31 |
* @see \Better_Payment\Lite\AI\Prompt\PromptBuilder Serialises this for the model. |
| 32 |
*/ |
| 33 |
class CampaignSchema { |
| 34 |
|
| 35 |
/** |
| 36 |
* Layout presets the builder understands. |
| 37 |
* |
| 38 |
* @return array<int, string> |
| 39 |
*/ |
| 40 |
public static function layout_presets(): array { |
| 41 |
return [ '1-column', '2-column', '3-column' ]; |
| 42 |
} |
| 43 |
|
| 44 |
/** |
| 45 |
* Every registered element type key (e.g. campaign_title, photo, ...). |
| 46 |
* |
| 47 |
* @return array<int, string> |
| 48 |
*/ |
| 49 |
public static function element_types(): array { |
| 50 |
return array_keys( ElementRegistry::get_all() ); |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* Whether a given element type is registered. |
| 55 |
* |
| 56 |
* Deliberately entitlement-BLIND. A campaign built while Pro was active may |
| 57 |
* still hold a `donors_wall`; when the licence lapses that element must |
| 58 |
* survive an AI edit turn untouched, exactly as |
| 59 |
* {@see \Better_Payment\Lite\Campaign\MetaBox::enforce_pro_entitlement()} |
| 60 |
* restores its settings rather than deleting it. Making this method |
| 61 |
* entitlement-aware would make an "edit the title" turn — whose `set_layout` |
| 62 |
* echoes the whole page back — silently delete the user's Pro elements. |
| 63 |
* |
| 64 |
* Entitlement decides what we *offer* ({@see self::offerable_element_types()}), |
| 65 |
* never what we recognise. |
| 66 |
*/ |
| 67 |
public static function is_element_type( string $type ): bool { |
| 68 |
return in_array( $type, self::element_types(), true ); |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* Whether Pro is active on this install, read live per request. |
| 73 |
* |
| 74 |
* Never inferred from stored campaign data — same contract the builder and |
| 75 |
* MetaBox follow. |
| 76 |
*/ |
| 77 |
public static function pro_enabled(): bool { |
| 78 |
return (bool) apply_filters( 'better_payment/pro_enabled', false ); |
| 79 |
} |
| 80 |
|
| 81 |
/** |
| 82 |
* Whether an element type is flagged Pro in the registry. |
| 83 |
* |
| 84 |
* The `pro` flag is present whether or not Pro is installed — Lite owns all |
| 85 |
* three schemas — so this asks "is this a Pro widget", not "is it locked". |
| 86 |
*/ |
| 87 |
public static function is_pro_element_type( string $type ): bool { |
| 88 |
$schema = ElementRegistry::get( $type ); |
| 89 |
|
| 90 |
return null !== $schema && ! empty( $schema['pro'] ); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* The element types this install may be offered: everything registered, minus |
| 95 |
* the Pro ones when Pro is inactive. |
| 96 |
* |
| 97 |
* A free install used to be handed `donors_wall`, `faq` and `video` in the |
| 98 |
* prompt under a rule reading "Only use the element types listed above" — so |
| 99 |
* the model reasonably used them, and they reached a page that has no |
| 100 |
* renderer for them and drops them to nothing. This is the list that decides |
| 101 |
* what the model is told exists. |
| 102 |
* |
| 103 |
* @return array<int, string> |
| 104 |
*/ |
| 105 |
public static function offerable_element_types(): array { |
| 106 |
$types = self::element_types(); |
| 107 |
|
| 108 |
if ( self::pro_enabled() ) { |
| 109 |
return $types; |
| 110 |
} |
| 111 |
|
| 112 |
return array_values( array_filter( |
| 113 |
$types, |
| 114 |
static function ( $type ) { |
| 115 |
return ! self::is_pro_element_type( $type ); |
| 116 |
} |
| 117 |
) ); |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Whether a type may be offered on this install (fed to the model in the |
| 122 |
* prompt schema). |
| 123 |
* |
| 124 |
* NOTE this is no longer the gate for *inserting* an element. A free user who |
| 125 |
* explicitly asks the AI for a Pro widget gets that exact widget dropped onto |
| 126 |
* the canvas as a locked preview — see |
| 127 |
* {@see \Better_Payment\Lite\AI\Operations\OperationValidator::validate_insert_block()} |
| 128 |
* and {@see self::locked_pro_types_in_operations()}. Offering (proactive) and |
| 129 |
* inserting (on explicit request) are now different questions. |
| 130 |
*/ |
| 131 |
public static function is_offerable_element_type( string $type ): bool { |
| 132 |
return in_array( $type, self::offerable_element_types(), true ); |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* The Pro element types this batch of operations puts onto the canvas that |
| 137 |
* the install is NOT entitled to — the widgets that will render as a locked |
| 138 |
* preview and stay inactive on the live page. |
| 139 |
* |
| 140 |
* This exists so the assistant can tell the user plainly, whatever the model |
| 141 |
* wrote in its own reply, that a widget they asked for is a Pro-only feature. |
| 142 |
* Reused by {@see \Better_Payment\Lite\AI\Services\AIService::run()}. |
| 143 |
* |
| 144 |
* Detection is deliberately "newly introduced", not merely "present". A |
| 145 |
* lapsed-Pro user's routine edit ("shorten the title") comes back as a |
| 146 |
* `set_layout` that echoes the whole page — leftover Pro widgets and all — and |
| 147 |
* announcing "FAQ is a Pro widget" on every such turn would be noise about |
| 148 |
* something the user did not just do. A type therefore counts only when an |
| 149 |
* `insert_block` creates it, or a `set_layout` adds one the current layout did |
| 150 |
* not already contain. Returns `[]` when Pro is active (nothing is locked). |
| 151 |
* |
| 152 |
* @param array $operations Validated operations (canonical `op` + type keys). |
| 153 |
* @param array $context Trusted current state: [ 'layout' => [...] ]. |
| 154 |
* @return array<int, string> Distinct Pro type keys, in first-seen order. |
| 155 |
*/ |
| 156 |
public static function locked_pro_types_in_operations( array $operations, array $context = [] ): array { |
| 157 |
if ( self::pro_enabled() ) { |
| 158 |
return []; |
| 159 |
} |
| 160 |
|
| 161 |
$existing = self::pro_types_in_columns( $context['layout']['columns'] ?? [] ); |
| 162 |
$found = []; |
| 163 |
|
| 164 |
foreach ( $operations as $operation ) { |
| 165 |
if ( ! is_array( $operation ) ) { |
| 166 |
continue; |
| 167 |
} |
| 168 |
$name = $operation['op'] ?? ( $operation['operation'] ?? '' ); |
| 169 |
|
| 170 |
if ( 'insert_block' === $name ) { |
| 171 |
$type = self::resolve_element_type( is_string( $operation['type'] ?? null ) ? $operation['type'] : '' ); |
| 172 |
if ( '' !== $type && self::is_pro_element_type( $type ) && ! in_array( $type, $found, true ) ) { |
| 173 |
$found[] = $type; |
| 174 |
} |
| 175 |
continue; |
| 176 |
} |
| 177 |
|
| 178 |
if ( 'set_layout' === $name ) { |
| 179 |
foreach ( self::pro_types_in_columns( $operation['columns'] ?? [] ) as $type ) { |
| 180 |
if ( ! in_array( $type, $existing, true ) && ! in_array( $type, $found, true ) ) { |
| 181 |
$found[] = $type; |
| 182 |
} |
| 183 |
} |
| 184 |
} |
| 185 |
} |
| 186 |
|
| 187 |
return $found; |
| 188 |
} |
| 189 |
|
| 190 |
/** |
| 191 |
* Distinct Pro element type keys present in a set of layout columns. |
| 192 |
* |
| 193 |
* @param mixed $columns |
| 194 |
* @return array<int, string> |
| 195 |
*/ |
| 196 |
private static function pro_types_in_columns( $columns ): array { |
| 197 |
$types = []; |
| 198 |
if ( ! is_array( $columns ) ) { |
| 199 |
return $types; |
| 200 |
} |
| 201 |
foreach ( $columns as $column ) { |
| 202 |
if ( ! is_array( $column ) || empty( $column['elements'] ) || ! is_array( $column['elements'] ) ) { |
| 203 |
continue; |
| 204 |
} |
| 205 |
foreach ( $column['elements'] as $element ) { |
| 206 |
if ( ! is_array( $element ) ) { |
| 207 |
continue; |
| 208 |
} |
| 209 |
$type = self::resolve_element_type( is_string( $element['type'] ?? null ) ? $element['type'] : '' ); |
| 210 |
if ( '' !== $type && self::is_pro_element_type( $type ) && ! in_array( $type, $types, true ) ) { |
| 211 |
$types[] = $type; |
| 212 |
} |
| 213 |
} |
| 214 |
} |
| 215 |
return $types; |
| 216 |
} |
| 217 |
|
| 218 |
/** |
| 219 |
* Pro element type keys registered on this install, regardless of entitlement. |
| 220 |
* |
| 221 |
* Used by the prompt builder to name the locked Pro widgets to a free install |
| 222 |
* (they are absent from {@see self::offerable_element_types()}), so the model |
| 223 |
* can honour an explicit request for one instead of substituting a free widget. |
| 224 |
* |
| 225 |
* @return array<int, string> |
| 226 |
*/ |
| 227 |
public static function pro_element_types(): array { |
| 228 |
return array_values( array_filter( |
| 229 |
self::element_types(), |
| 230 |
[ self::class, 'is_pro_element_type' ] |
| 231 |
) ); |
| 232 |
} |
| 233 |
|
| 234 |
/** |
| 235 |
* Common alias → canonical element type. Models frequently emit generic names |
| 236 |
* (heading, paragraph, image, button …) instead of the registry keys; mapping |
| 237 |
* them recovers the element instead of silently dropping it. |
| 238 |
* |
| 239 |
* @return array<string, string> |
| 240 |
*/ |
| 241 |
private static function type_aliases(): array { |
| 242 |
return apply_filters( 'better_payment/ai/element_type_aliases', [ |
| 243 |
'heading' => 'campaign_title', 'title' => 'campaign_title', 'header' => 'campaign_title', 'headline' => 'campaign_title', 'campaigntitle' => 'campaign_title', |
| 244 |
'paragraph' => 'campaign_description', 'text' => 'campaign_description', 'story' => 'campaign_description', 'description' => 'campaign_description', 'content' => 'campaign_description', 'body' => 'campaign_description', 'richtext' => 'campaign_description', 'rich_text' => 'campaign_description', |
| 245 |
'image' => 'photo', 'img' => 'photo', 'picture' => 'photo', 'hero_image' => 'photo', 'heroimage' => 'photo', 'gallery' => 'photo', |
| 246 |
'button' => 'donation_form', 'cta' => 'donation_form', 'donate_button' => 'donation_form', 'donatebutton' => 'donation_form', 'donate' => 'donation_form', 'donatenow' => 'donation_form', 'donate_now' => 'donation_form', |
| 247 |
'progress' => 'progress_bar', 'progressbar' => 'progress_bar', |
| 248 |
'donation' => 'donate_amount', 'amounts' => 'donate_amount', 'donation_amounts' => 'donate_amount', 'suggested_amounts' => 'donate_amount', 'donation_amount' => 'donate_amount', 'tiers' => 'donate_amount', |
| 249 |
'summary' => 'campaign_summary', 'stats' => 'campaign_summary', 'statistics' => 'campaign_summary', 'campaignsummary' => 'campaign_summary', |
| 250 |
'share' => 'social_sharing', 'sharing' => 'social_sharing', 'social' => 'social_sharing', 'socialshare' => 'social_sharing', 'social_share' => 'social_sharing', |
| 251 |
'links' => 'social_links', 'sociallinks' => 'social_links', 'social_link' => 'social_links', |
| 252 |
'author' => 'organizer', 'creator' => 'organizer', 'organizer_card' => 'organizer', 'host' => 'organizer', |
| 253 |
// Pro elements. Aliased unconditionally — resolution is not permission, |
| 254 |
// and a free install simply never sees these types offered. |
| 255 |
'donors' => 'donors_wall', 'donorswall' => 'donors_wall', 'donor_list' => 'donors_wall', 'donorlist' => 'donors_wall', 'supporters' => 'donors_wall', 'recent_donors' => 'donors_wall', 'contributors' => 'donors_wall', |
| 256 |
'faqs' => 'faq', 'questions' => 'faq', 'accordion' => 'faq', 'q_and_a' => 'faq', 'qa' => 'faq', 'frequently_asked_questions' => 'faq', |
| 257 |
'youtube' => 'video', 'vimeo' => 'video', 'embed' => 'video', 'video_embed' => 'video', 'media' => 'video', |
| 258 |
] ); |
| 259 |
} |
| 260 |
|
| 261 |
/** |
| 262 |
* Resolve a possibly-aliased element type to a registered type, or '' when it |
| 263 |
* cannot be mapped. |
| 264 |
*/ |
| 265 |
public static function resolve_element_type( string $type ): string { |
| 266 |
$t = strtolower( trim( $type ) ); |
| 267 |
if ( self::is_element_type( $t ) ) { |
| 268 |
return $t; |
| 269 |
} |
| 270 |
$norm = preg_replace( '/[\s\-]+/', '_', $t ); |
| 271 |
if ( self::is_element_type( $norm ) ) { |
| 272 |
return $norm; |
| 273 |
} |
| 274 |
$aliases = self::type_aliases(); |
| 275 |
if ( isset( $aliases[ $t ] ) ) { |
| 276 |
return $aliases[ $t ]; |
| 277 |
} |
| 278 |
if ( isset( $aliases[ $norm ] ) ) { |
| 279 |
return $aliases[ $norm ]; |
| 280 |
} |
| 281 |
return ''; |
| 282 |
} |
| 283 |
|
| 284 |
/** |
| 285 |
* Per-type alias → canonical setting key, so a mis-named content key |
| 286 |
* (e.g. `text` on a title) still lands in the right place. |
| 287 |
* |
| 288 |
* @return array<string, array<string, string>> |
| 289 |
*/ |
| 290 |
private static function setting_key_aliases(): array { |
| 291 |
return apply_filters( 'better_payment/ai/setting_key_aliases', [ |
| 292 |
'campaign_title' => [ 'text' => 'title', 'heading' => 'title', 'label' => 'title', 'headline' => 'title', 'name' => 'title' ], |
| 293 |
'campaign_description' => [ 'text' => 'content', 'body' => 'content', 'paragraph' => 'content', 'description' => 'content', 'story' => 'content', 'heading' => 'headline', 'title' => 'headline' ], |
| 294 |
'donation_form' => [ 'text' => 'button_label', 'label' => 'button_label', 'cta' => 'button_label', 'button_text' => 'button_label', 'title' => 'button_label' ], |
| 295 |
'photo' => [ 'url' => 'src', 'image' => 'src', 'source' => 'src', 'href' => 'src' ], |
| 296 |
'progress_bar' => [ 'title' => 'headline', 'text' => 'headline' ], |
| 297 |
'campaign_summary' => [ 'title' => 'headline', 'text' => 'headline' ], |
| 298 |
'donate_amount' => [ 'title' => 'headline', 'text' => 'headline' ], |
| 299 |
'organizer' => [ 'text' => 'description', 'bio' => 'description', 'name' => 'role_title', 'title' => 'role_title' ], |
| 300 |
'social_sharing' => [ 'title' => 'headline', 'text' => 'headline' ], |
| 301 |
'social_links' => [ 'title' => 'headline', 'text' => 'headline' ], |
| 302 |
'donors_wall' => [ 'title' => 'headline', 'text' => 'headline', 'heading' => 'headline', 'limit' => 'number_to_show', 'count' => 'number_to_show' ], |
| 303 |
// `faq` names its heading `heading`, not `headline` — the one element |
| 304 |
// that breaks the pattern, so the reverse alias matters here. |
| 305 |
'faq' => [ 'title' => 'heading', 'text' => 'heading', 'headline' => 'heading', 'questions' => 'items', 'faqs' => 'items', 'list' => 'items' ], |
| 306 |
'video' => [ 'src' => 'url', 'link' => 'url', 'video_url' => 'url', 'embed_url' => 'url' ], |
| 307 |
] ); |
| 308 |
} |
| 309 |
|
| 310 |
/** |
| 311 |
* Resolve a possibly-aliased setting key for a type. Returns the canonical key |
| 312 |
* when it is (or maps to) an allowed key, otherwise the original (which the |
| 313 |
* allowlist will then drop). |
| 314 |
*/ |
| 315 |
public static function resolve_setting_key( string $type, string $key ): string { |
| 316 |
if ( self::is_allowed_settings_key( $type, $key ) ) { |
| 317 |
return $key; |
| 318 |
} |
| 319 |
$map = self::setting_key_aliases(); |
| 320 |
$k = strtolower( trim( $key ) ); |
| 321 |
if ( isset( $map[ $type ][ $k ] ) ) { |
| 322 |
return $map[ $type ][ $k ]; |
| 323 |
} |
| 324 |
return $key; |
| 325 |
} |
| 326 |
|
| 327 |
/** |
| 328 |
* Campaign-level meta keys the AI is allowed to write. |
| 329 |
* |
| 330 |
* Deliberately a subset of {@see \Better_Payment\Lite\Campaign\MetaBox} keys: |
| 331 |
* layout (bpc_fields_layout), the form page id and the template key are |
| 332 |
* managed structurally, not by free-form AI meta writes. |
| 333 |
* |
| 334 |
* @return array<int, string> |
| 335 |
*/ |
| 336 |
public static function writable_meta_keys(): array { |
| 337 |
$keys = [ |
| 338 |
'title', |
| 339 |
'bpc_goal_amount', |
| 340 |
'bpc_end_date', |
| 341 |
'bpc_status', |
| 342 |
'bpc_allow_custom_amount', |
| 343 |
'bpc_minimum_amount', |
| 344 |
'bpc_color_primary', |
| 345 |
'bpc_color_background', |
| 346 |
'bpc_css_class', |
| 347 |
'bpc_suggested_amounts', |
| 348 |
]; |
| 349 |
|
| 350 |
/** |
| 351 |
* Filter the campaign meta keys the AI layer may write. |
| 352 |
* |
| 353 |
* @param array<int, string> $keys |
| 354 |
*/ |
| 355 |
return apply_filters( 'better_payment/ai/writable_meta_keys', $keys ); |
| 356 |
} |
| 357 |
|
| 358 |
public static function is_writable_meta_key( string $key ): bool { |
| 359 |
return in_array( $key, self::writable_meta_keys(), true ); |
| 360 |
} |
| 361 |
|
| 362 |
/** |
| 363 |
* The full set of setting keys a given element type accepts. |
| 364 |
* |
| 365 |
* Built from the element's defaultSettings keys plus every control key in |
| 366 |
* its settingsSchema (recursing into collapsible `section` children). Synthetic |
| 367 |
* container keys (sections, notes — always prefixed with `_`) are excluded. |
| 368 |
* |
| 369 |
* @return array<int, string> |
| 370 |
*/ |
| 371 |
public static function allowed_settings_keys( string $type ): array { |
| 372 |
$schema = ElementRegistry::get( $type ); |
| 373 |
if ( null === $schema ) { |
| 374 |
return []; |
| 375 |
} |
| 376 |
|
| 377 |
$keys = array_keys( (array) ( $schema['defaultSettings'] ?? [] ) ); |
| 378 |
|
| 379 |
$collect = function ( $controls, &$out ) use ( &$collect ) { |
| 380 |
foreach ( (array) $controls as $control ) { |
| 381 |
if ( ! is_array( $control ) ) { |
| 382 |
continue; |
| 383 |
} |
| 384 |
$ctype = $control['type'] ?? ''; |
| 385 |
if ( in_array( $ctype, [ 'section', 'section_label', 'note' ], true ) ) { |
| 386 |
if ( ! empty( $control['children'] ) ) { |
| 387 |
$collect( $control['children'], $out ); |
| 388 |
} |
| 389 |
continue; |
| 390 |
} |
| 391 |
if ( ! empty( $control['key'] ) && 0 !== strpos( (string) $control['key'], '_' ) ) { |
| 392 |
$out[] = (string) $control['key']; |
| 393 |
} |
| 394 |
if ( ! empty( $control['children'] ) ) { |
| 395 |
$collect( $control['children'], $out ); |
| 396 |
} |
| 397 |
} |
| 398 |
}; |
| 399 |
|
| 400 |
$collect( $schema['settingsSchema'] ?? [], $keys ); |
| 401 |
|
| 402 |
return array_values( array_unique( $keys ) ); |
| 403 |
} |
| 404 |
|
| 405 |
public static function is_allowed_settings_key( string $type, string $key ): bool { |
| 406 |
return in_array( $key, self::allowed_settings_keys( $type ), true ); |
| 407 |
} |
| 408 |
|
| 409 |
/** |
| 410 |
* Enumerated values for a select/align control, or null when the key is free-form. |
| 411 |
* |
| 412 |
* @return array<int, string>|null |
| 413 |
*/ |
| 414 |
public static function enum_values( string $type, string $key ) { |
| 415 |
// `align` controls carry no options array but accept a fixed set. |
| 416 |
$control = self::find_control( $type, $key ); |
| 417 |
if ( null === $control ) { |
| 418 |
return null; |
| 419 |
} |
| 420 |
if ( 'align' === ( $control['type'] ?? '' ) ) { |
| 421 |
return [ 'left', 'center', 'right' ]; |
| 422 |
} |
| 423 |
if ( ! empty( $control['options'] ) && is_array( $control['options'] ) ) { |
| 424 |
$values = []; |
| 425 |
foreach ( $control['options'] as $option ) { |
| 426 |
if ( is_array( $option ) && array_key_exists( 'value', $option ) ) { |
| 427 |
$values[] = (string) $option['value']; |
| 428 |
} |
| 429 |
} |
| 430 |
return $values; |
| 431 |
} |
| 432 |
return null; |
| 433 |
} |
| 434 |
|
| 435 |
/** |
| 436 |
* Locate a single control definition (recursing sections) for an element key. |
| 437 |
* |
| 438 |
* @return array|null |
| 439 |
*/ |
| 440 |
public static function find_control( string $type, string $key ) { |
| 441 |
$schema = ElementRegistry::get( $type ); |
| 442 |
if ( null === $schema ) { |
| 443 |
return null; |
| 444 |
} |
| 445 |
|
| 446 |
$search = function ( $controls ) use ( &$search, $key ) { |
| 447 |
foreach ( (array) $controls as $control ) { |
| 448 |
if ( ! is_array( $control ) ) { |
| 449 |
continue; |
| 450 |
} |
| 451 |
if ( ( $control['key'] ?? null ) === $key ) { |
| 452 |
return $control; |
| 453 |
} |
| 454 |
if ( ! empty( $control['children'] ) ) { |
| 455 |
$found = $search( $control['children'] ); |
| 456 |
if ( null !== $found ) { |
| 457 |
return $found; |
| 458 |
} |
| 459 |
} |
| 460 |
} |
| 461 |
return null; |
| 462 |
}; |
| 463 |
|
| 464 |
return $search( $schema['settingsSchema'] ?? [] ); |
| 465 |
} |
| 466 |
|
| 467 |
/** |
| 468 |
* A compact, prompt-friendly description of the whole builder capability set. |
| 469 |
* |
| 470 |
* Shape: |
| 471 |
* [ |
| 472 |
* 'layout_presets' => [...], |
| 473 |
* 'meta_keys' => [...], |
| 474 |
* 'elements' => [ |
| 475 |
* [ 'type' => 'campaign_title', 'label' => '...', 'guide' => '...', |
| 476 |
* 'settings' => [ 'title' => [ 'type' => 'text' ], |
| 477 |
* 'align' => [ 'type' => 'align', 'enum' => ['left','center','right'] ] ] ], |
| 478 |
* ... |
| 479 |
* ], |
| 480 |
* ] |
| 481 |
* |
| 482 |
* Only settable, non-container keys are described, keeping the prompt small. |
| 483 |
* |
| 484 |
* Two things shape this list beyond the raw registry: |
| 485 |
* - Pro elements are omitted unless Pro is active. Describing a widget the |
| 486 |
* install cannot render is not a harmless extra option: the rules tell the |
| 487 |
* model these types are the ones it may use. |
| 488 |
* - Each element carries a `guide` — what its content is actually *for*. |
| 489 |
* A key list alone is not self-describing, and three elements sharing a |
| 490 |
* key named `headline` otherwise get three copies of the same sentence. |
| 491 |
* |
| 492 |
* @return array<string, mixed> |
| 493 |
*/ |
| 494 |
public static function for_prompt(): array { |
| 495 |
$elements = []; |
| 496 |
|
| 497 |
foreach ( self::offerable_element_types() as $type ) { |
| 498 |
$elements[] = self::describe_element( $type ); |
| 499 |
} |
| 500 |
|
| 501 |
return [ |
| 502 |
'layout_presets' => self::layout_presets(), |
| 503 |
'meta_keys' => self::writable_meta_keys(), |
| 504 |
'elements' => $elements, |
| 505 |
]; |
| 506 |
} |
| 507 |
|
| 508 |
/** |
| 509 |
* Describe ONE element type: its label, its content guide, and every settable |
| 510 |
* key with that key's control type and enum. |
| 511 |
* |
| 512 |
* Extracted from {@see self::for_prompt()} so the whole-page schema and the |
| 513 |
* single-widget "target element" prompt section are built from the same code. |
| 514 |
* Two builders would drift, and a drift here is not cosmetic — the targeted |
| 515 |
* section exists precisely to tell the model which fields the widget in front |
| 516 |
* of the user actually has, so a stale copy would describe a widget that is |
| 517 |
* not the one being edited. |
| 518 |
* |
| 519 |
* Deliberately **entitlement-blind**, unlike {@see self::for_prompt()}. This |
| 520 |
* describes an element that already exists on the page, and the caller has |
| 521 |
* already decided it may be edited; filtering here would leave a lapsed |
| 522 |
* subscriber's Pro widget selectable but undescribable, so the model would be |
| 523 |
* asked to edit a widget it had been told nothing about. Entitlement decides |
| 524 |
* what is *offered* (`offerable_element_types()`) and what may be newly |
| 525 |
* *inserted* (`validate_insert_block()`) — never what is recognised. |
| 526 |
* |
| 527 |
* @param string $type Element type key. |
| 528 |
* @return array{type: string, label: string, guide: string, settings: array<string, array>} |
| 529 |
*/ |
| 530 |
public static function describe_element( string $type ): array { |
| 531 |
$schema = ElementRegistry::get( $type ); |
| 532 |
$schema = is_array( $schema ) ? $schema : []; |
| 533 |
|
| 534 |
$settings = []; |
| 535 |
foreach ( self::allowed_settings_keys( $type ) as $key ) { |
| 536 |
$control = self::find_control( $type, $key ); |
| 537 |
$entry = [ 'type' => $control['type'] ?? 'text' ]; |
| 538 |
$enum = self::enum_values( $type, $key ); |
| 539 |
if ( null !== $enum && ! empty( $enum ) ) { |
| 540 |
$entry['enum'] = $enum; |
| 541 |
} |
| 542 |
if ( ! empty( $control['label'] ) ) { |
| 543 |
$entry['label'] = (string) $control['label']; |
| 544 |
} |
| 545 |
$settings[ $key ] = $entry; |
| 546 |
} |
| 547 |
|
| 548 |
return [ |
| 549 |
'type' => $type, |
| 550 |
'label' => (string) ( $schema['label'] ?? $type ), |
| 551 |
'guide' => ElementContentGuide::for_type( $type ), |
| 552 |
'settings' => $settings, |
| 553 |
]; |
| 554 |
} |
| 555 |
} |
| 556 |
|