| 1 |
<?php |
| 2 |
|
| 3 |
namespace Better_Payment\Lite\AI\Layout; |
| 4 |
|
| 5 |
use Better_Payment\Lite\AI\Schema\CampaignSchema; |
| 6 |
|
| 7 |
if ( ! defined( 'ABSPATH' ) ) { |
| 8 |
exit; |
| 9 |
} |
| 10 |
|
| 11 |
/** |
| 12 |
* Reusable layout blueprint library for AI campaign generation. |
| 13 |
* |
| 14 |
* The AI used to default to the same 2-column "story + donation sidebar" every |
| 15 |
* time, so generated campaigns felt cloned. This library gives generation a |
| 16 |
* varied palette of professionally-composed page *structures* — different layout |
| 17 |
* presets, column arrangements, widths, section sets, ordering and donation |
| 18 |
* placement — all expressed with the EXISTING element types and layout presets |
| 19 |
* (no new widgets/blocks). When the user hasn't pre-chosen a layout, the |
| 20 |
* generator picks a blueprint (weighted, category-aware, avoiding immediate |
| 21 |
* repeats) and builds the campaign into it. The result is controlled creativity: |
| 22 |
* every campaign varies structurally, but each one still follows sound design. |
| 23 |
* |
| 24 |
* A blueprint is pure configuration, so new patterns can be added — here or via |
| 25 |
* the `better_payment/ai/layout_blueprints` filter — without touching the |
| 26 |
* generation logic. |
| 27 |
* |
| 28 |
* Blueprint shape: |
| 29 |
* [ |
| 30 |
* 'key' => string, // unique |
| 31 |
* 'name' => string, // human name, shown to the model |
| 32 |
* 'weight' => int, // selection weight |
| 33 |
* 'tags' => array<string>, // campaign categories it suits |
| 34 |
* 'preset' => '1-column'|'2-column'|'3-column'|'split', |
| 35 |
* 'columns' => [ [ 'id','label','width','role', |
| 36 |
* 'suggested' => [element types], |
| 37 |
* 'pro' => [Pro element types] ], ... ], |
| 38 |
* ] |
| 39 |
* |
| 40 |
* `pro` names the Pro widgets that genuinely belong in that column — appended to |
| 41 |
* `suggested` only when Pro is active ({@see self::suggested_for()}). Without it, |
| 42 |
* a paying install generated pages made entirely of free widgets: the schema |
| 43 |
* listed the Pro elements but nothing ever proposed *where* one should go, and |
| 44 |
* the model builds from the design direction, not from the type list. Placement |
| 45 |
* is per-column on purpose — a Donors Wall belongs beside the donation ask, an |
| 46 |
* FAQ belongs under the story, and a video belongs in the hero. |
| 47 |
* |
| 48 |
* @see \Better_Payment\Lite\AI\Services\CampaignGenerator Consumer. |
| 49 |
*/ |
| 50 |
class LayoutLibrary { |
| 51 |
|
| 52 |
/** |
| 53 |
* Keyword → category map used to infer a campaign's category from its brief. |
| 54 |
* |
| 55 |
* @return array<string, array<int, string>> |
| 56 |
*/ |
| 57 |
private static function category_keywords(): array { |
| 58 |
// Matched as whole words (see infer_categories), so include the inflected |
| 59 |
// forms that matter rather than bare stems. |
| 60 |
return [ |
| 61 |
'medical' => [ 'medical', 'surgery', 'surgeries', 'cancer', 'hospital', 'treatment', 'health', 'illness', 'disease', 'transplant', 'therapy', 'diagnosis', 'diagnosed' ], |
| 62 |
'emergency' => [ 'emergency', 'urgent', 'disaster', 'relief', 'crisis', 'flood', 'fire', 'earthquake', 'hurricane', 'refugee', 'refugees', 'evacuate', 'evacuated' ], |
| 63 |
'memorial' => [ 'memorial', 'funeral', 'memory', 'tribute', 'grief', 'loss', 'passed away' ], |
| 64 |
'animal' => [ 'animal', 'animals', 'dog', 'dogs', 'cat', 'cats', 'shelter', 'wildlife', 'pet', 'pets', 'rescue', 'paws', 'kitten', 'kittens', 'puppy', 'puppies' ], |
| 65 |
'education' => [ 'school', 'schools', 'student', 'students', 'education', 'scholarship', 'college', 'tuition', 'learning', 'library', 'classroom', 'books' ], |
| 66 |
'community' => [ 'community', 'neighborhood', 'neighbourhood', 'local', 'together', 'village', 'town' ], |
| 67 |
'event' => [ 'event', 'marathon', 'gala', 'tournament', 'sports', 'team', 'concert', 'festival', 'challenge' ], |
| 68 |
'nonprofit' => [ 'nonprofit', 'non-profit', 'charity', 'foundation', 'ngo', 'mission', 'humanitarian' ], |
| 69 |
'creative' => [ 'creative', 'art', 'film', 'music', 'album', 'book', 'startup', 'invention', 'documentary' ], |
| 70 |
]; |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* Curated primary-colour palettes with mood tags, for colour variation. |
| 75 |
* |
| 76 |
* @return array<int, array{primary: string, mood: string, tags: array<int, string>}> |
| 77 |
*/ |
| 78 |
private static function palettes(): array { |
| 79 |
return [ |
| 80 |
[ 'primary' => '#6b63f6', 'mood' => 'trustworthy and modern', 'tags' => [ 'general', 'nonprofit', 'creative', 'event' ] ], |
| 81 |
[ 'primary' => '#e0533f', 'mood' => 'urgent and heartfelt', 'tags' => [ 'emergency', 'medical' ] ], |
| 82 |
[ 'primary' => '#2ea56b', 'mood' => 'hopeful and reassuring', 'tags' => [ 'medical', 'community', 'animal', 'education' ] ], |
| 83 |
[ 'primary' => '#3b6ef5', 'mood' => 'calm and credible', 'tags' => [ 'nonprofit', 'education', 'general' ] ], |
| 84 |
[ 'primary' => '#ef8f2b', 'mood' => 'warm and welcoming', 'tags' => [ 'community', 'event', 'animal' ] ], |
| 85 |
[ 'primary' => '#0ea5a5', 'mood' => 'fresh and optimistic', 'tags' => [ 'creative', 'community', 'general' ] ], |
| 86 |
[ 'primary' => '#8b5cf6', 'mood' => 'gentle and dignified', 'tags' => [ 'memorial', 'nonprofit' ] ], |
| 87 |
[ 'primary' => '#d64d76', 'mood' => 'compassionate and personal', 'tags' => [ 'medical', 'memorial', 'community' ] ], |
| 88 |
]; |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* The built-in blueprints. Widths in a row total ~100%; `split` renders as a |
| 93 |
* full-width row (first column) then two 50/50 columns. |
| 94 |
* |
| 95 |
* @return array<int, array> |
| 96 |
*/ |
| 97 |
private static function blueprints(): array { |
| 98 |
return [ |
| 99 |
[ |
| 100 |
'key' => 'story-sidebar', |
| 101 |
'name' => 'Story with donation sidebar', |
| 102 |
'weight' => 3, |
| 103 |
'tags' => [ 'general', 'medical', 'community', 'nonprofit' ], |
| 104 |
'preset' => '2-column', |
| 105 |
'columns' => [ |
| 106 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '64%', 'role' => 'Hero title, image and the campaign story, ending with the progress bar', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'progress_bar' ], 'pro' => [ 'video', 'faq' ] ], |
| 107 |
[ 'id' => 'sidebar', 'label' => 'Sidebar', 'width' => '36%', 'role' => 'Donation actions and quick stats', 'suggested' => [ 'donation_form', 'donate_amount', 'campaign_summary', 'social_sharing' ], 'pro' => [ 'donors_wall' ] ], |
| 108 |
], |
| 109 |
], |
| 110 |
[ |
| 111 |
'key' => 'donate-first', |
| 112 |
'name' => 'Donation-first (action on the left)', |
| 113 |
'weight' => 2, |
| 114 |
'tags' => [ 'emergency', 'medical', 'nonprofit' ], |
| 115 |
'preset' => '2-column', |
| 116 |
'columns' => [ |
| 117 |
[ 'id' => 'action', 'label' => 'Donate', 'width' => '42%', 'role' => 'Prominent donation panel with amounts, progress and summary', 'suggested' => [ 'donate_amount', 'donation_form', 'progress_bar', 'campaign_summary' ], 'pro' => [ 'donors_wall' ] ], |
| 118 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '58%', 'role' => 'Title, image, story and organizer', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'organizer' ], 'pro' => [ 'video', 'faq' ] ], |
| 119 |
], |
| 120 |
], |
| 121 |
[ |
| 122 |
'key' => 'full-story', |
| 123 |
'name' => 'Full-width storytelling', |
| 124 |
'weight' => 2, |
| 125 |
'tags' => [ 'memorial', 'community', 'nonprofit', 'general' ], |
| 126 |
'preset' => '1-column', |
| 127 |
'columns' => [ |
| 128 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '100%', 'role' => 'A single vertical narrative: hero, story, progress, then the donation ask and organizer', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'progress_bar', 'donate_amount', 'donation_form', 'organizer', 'social_sharing' ], 'pro' => [ 'video', 'faq', 'donors_wall' ] ], |
| 129 |
], |
| 130 |
], |
| 131 |
[ |
| 132 |
'key' => 'hero-split', |
| 133 |
'name' => 'Hero on top, story and donation below', |
| 134 |
'weight' => 3, |
| 135 |
'tags' => [ 'event', 'community', 'general', 'creative' ], |
| 136 |
'preset' => 'split', |
| 137 |
'columns' => [ |
| 138 |
[ 'id' => 'hero', 'label' => 'Hero', 'width' => '100%', 'role' => 'Full-width hero: title and a strong image', 'suggested' => [ 'campaign_title', 'photo' ], 'pro' => [ 'video' ] ], |
| 139 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '50%', 'role' => 'The story, progress and organizer', 'suggested' => [ 'campaign_description', 'progress_bar', 'organizer' ], 'pro' => [ 'faq' ] ], |
| 140 |
[ 'id' => 'donate', 'label' => 'Donate', 'width' => '50%', 'role' => 'Summary and the donation ask', 'suggested' => [ 'campaign_summary', 'donate_amount', 'donation_form', 'social_sharing' ], 'pro' => [ 'donors_wall' ] ], |
| 141 |
], |
| 142 |
], |
| 143 |
[ |
| 144 |
'key' => 'impact-split', |
| 145 |
'name' => 'Impact overview then story and donation', |
| 146 |
'weight' => 2, |
| 147 |
'tags' => [ 'nonprofit', 'medical', 'education', 'community' ], |
| 148 |
'preset' => 'split', |
| 149 |
'columns' => [ |
| 150 |
[ 'id' => 'intro', 'label' => 'Intro', 'width' => '100%', 'role' => 'Full-width title and a concise mission statement', 'suggested' => [ 'campaign_title', 'campaign_description' ], 'pro' => [ 'video' ] ], |
| 151 |
[ 'id' => 'impact', 'label' => 'Impact', 'width' => '50%', 'role' => 'Visual impact: image, summary stats and progress', 'suggested' => [ 'photo', 'campaign_summary', 'progress_bar' ], 'pro' => [ 'faq' ] ], |
| 152 |
[ 'id' => 'donate', 'label' => 'Donate', 'width' => '50%', 'role' => 'Donation amounts, the form and sharing', 'suggested' => [ 'donate_amount', 'donation_form', 'social_sharing' ], 'pro' => [ 'donors_wall' ] ], |
| 153 |
], |
| 154 |
], |
| 155 |
[ |
| 156 |
'key' => 'minimal', |
| 157 |
'name' => 'Minimal landing page', |
| 158 |
'weight' => 1, |
| 159 |
'tags' => [ 'event', 'creative', 'general' ], |
| 160 |
'preset' => '1-column', |
| 161 |
'columns' => [ |
| 162 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '100%', 'role' => 'A short, focused page: title, one tight paragraph, progress and a single clear donate action', 'suggested' => [ 'campaign_title', 'campaign_description', 'progress_bar', 'donation_form' ] ], |
| 163 |
], |
| 164 |
], |
| 165 |
[ |
| 166 |
'key' => 'modern-trio', |
| 167 |
'name' => 'Three-column overview', |
| 168 |
'weight' => 1, |
| 169 |
'tags' => [ 'creative', 'community', 'nonprofit', 'event' ], |
| 170 |
'preset' => '3-column', |
| 171 |
'columns' => [ |
| 172 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '38%', 'role' => 'Title and the story', 'suggested' => [ 'campaign_title', 'campaign_description' ], 'pro' => [ 'faq' ] ], |
| 173 |
[ 'id' => 'visual', 'label' => 'Visual', 'width' => '32%', 'role' => 'Image and progress', 'suggested' => [ 'photo', 'progress_bar' ], 'pro' => [ 'video' ] ], |
| 174 |
[ 'id' => 'action', 'label' => 'Action', 'width' => '30%', 'role' => 'Amounts and the donation form', 'suggested' => [ 'donate_amount', 'donation_form', 'social_sharing' ], 'pro' => [ 'donors_wall' ] ], |
| 175 |
], |
| 176 |
], |
| 177 |
[ |
| 178 |
'key' => 'organizer-led', |
| 179 |
'name' => 'Organizer-led (profile sidebar on the left)', |
| 180 |
'weight' => 1, |
| 181 |
'tags' => [ 'nonprofit', 'community', 'memorial' ], |
| 182 |
'preset' => '2-column', |
| 183 |
'columns' => [ |
| 184 |
[ 'id' => 'profile', 'label' => 'Organizer', 'width' => '36%', 'role' => 'Who is running this: organizer card, summary and links', 'suggested' => [ 'organizer', 'campaign_summary', 'social_links' ], 'pro' => [ 'donors_wall' ] ], |
| 185 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '64%', 'role' => 'Title, image, story, progress and the donation ask', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'progress_bar', 'donation_form' ], 'pro' => [ 'video', 'faq' ] ], |
| 186 |
], |
| 187 |
], |
| 188 |
[ |
| 189 |
'key' => 'gallery-story', |
| 190 |
'name' => 'Gallery storytelling', |
| 191 |
'weight' => 1, |
| 192 |
'tags' => [ 'event', 'creative', 'community', 'animal' ], |
| 193 |
'preset' => '1-column', |
| 194 |
'columns' => [ |
| 195 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '100%', 'role' => 'An image-rich narrative: title, image, story, a second image, progress, then donate and share', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'photo', 'progress_bar', 'donation_form', 'social_sharing' ], 'pro' => [ 'video', 'donors_wall', 'faq' ] ], |
| 196 |
], |
| 197 |
], |
| 198 |
[ |
| 199 |
'key' => 'urgent-emergency', |
| 200 |
'name' => 'Urgent emergency appeal', |
| 201 |
'weight' => 2, |
| 202 |
'tags' => [ 'emergency', 'medical', 'animal' ], |
| 203 |
'preset' => '2-column', |
| 204 |
'columns' => [ |
| 205 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '58%', 'role' => 'An urgent title, the situation, live progress and an image', 'suggested' => [ 'campaign_title', 'campaign_description', 'progress_bar', 'photo' ], 'pro' => [ 'video' ] ], |
| 206 |
[ 'id' => 'sidebar', 'label' => 'Sidebar', 'width' => '42%', 'role' => 'Immediate donation ask with amounts and summary', 'suggested' => [ 'donate_amount', 'donation_form', 'campaign_summary', 'social_sharing' ], 'pro' => [ 'donors_wall' ] ], |
| 207 |
], |
| 208 |
], |
| 209 |
[ |
| 210 |
'key' => 'balanced', |
| 211 |
'name' => 'Balanced two-column', |
| 212 |
'weight' => 1, |
| 213 |
'tags' => [ 'general', 'education', 'community', 'creative' ], |
| 214 |
'preset' => '2-column', |
| 215 |
'columns' => [ |
| 216 |
[ 'id' => 'left', 'label' => 'Left', 'width' => '50%', 'role' => 'Title, image and the story', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description' ], 'pro' => [ 'video', 'faq' ] ], |
| 217 |
[ 'id' => 'right', 'label' => 'Right', 'width' => '50%', 'role' => 'Progress, amounts, the form and summary', 'suggested' => [ 'progress_bar', 'donate_amount', 'donation_form', 'campaign_summary' ], 'pro' => [ 'donors_wall' ] ], |
| 218 |
], |
| 219 |
], |
| 220 |
[ |
| 221 |
'key' => 'progress-hero-split', |
| 222 |
'name' => 'Progress hero, then story and donation', |
| 223 |
'weight' => 1, |
| 224 |
'tags' => [ 'medical', 'emergency', 'education', 'general' ], |
| 225 |
'preset' => 'split', |
| 226 |
'columns' => [ |
| 227 |
[ 'id' => 'hero', 'label' => 'Hero', 'width' => '100%', 'role' => 'Full-width title with the progress bar right up top', 'suggested' => [ 'campaign_title', 'progress_bar' ], 'pro' => [ 'video' ] ], |
| 228 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '50%', 'role' => 'Image and the story', 'suggested' => [ 'photo', 'campaign_description' ], 'pro' => [ 'faq' ] ], |
| 229 |
[ 'id' => 'donate', 'label' => 'Donate', 'width' => '50%', 'role' => 'Amounts, the form and summary', 'suggested' => [ 'donate_amount', 'donation_form', 'campaign_summary', 'social_sharing' ], 'pro' => [ 'donors_wall' ] ], |
| 230 |
], |
| 231 |
], |
| 232 |
]; |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* All blueprints, after the extension filter. |
| 237 |
* |
| 238 |
* @return array<int, array> |
| 239 |
*/ |
| 240 |
public static function all(): array { |
| 241 |
/** |
| 242 |
* Filter the AI layout blueprints. |
| 243 |
* |
| 244 |
* @param array<int, array> $blueprints |
| 245 |
*/ |
| 246 |
return apply_filters( 'better_payment/ai/layout_blueprints', self::blueprints() ); |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* Infer the campaign's category tags from its brief (keyword match). |
| 251 |
* |
| 252 |
* @return array<int, string> |
| 253 |
*/ |
| 254 |
public static function infer_categories( string $brief ): array { |
| 255 |
$haystack = strtolower( $brief ); |
| 256 |
$matched = []; |
| 257 |
foreach ( self::category_keywords() as $category => $keywords ) { |
| 258 |
foreach ( $keywords as $kw ) { |
| 259 |
// Whole-word match so short words don't hit inside others |
| 260 |
// (e.g. "cat" must not match "category"). |
| 261 |
if ( preg_match( '/\b' . preg_quote( $kw, '/' ) . '\b/', $haystack ) ) { |
| 262 |
$matched[] = $category; |
| 263 |
break; |
| 264 |
} |
| 265 |
} |
| 266 |
} |
| 267 |
return $matched; |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* Pick a blueprint for a brief: prefer ones matching the inferred category, |
| 272 |
* weight the choice, and avoid repeating $exclude_key when possible. |
| 273 |
* |
| 274 |
* @param string $brief |
| 275 |
* @param string $exclude_key Blueprint key to avoid (e.g. the last one used). |
| 276 |
* @return array The chosen blueprint. |
| 277 |
*/ |
| 278 |
public static function pick( string $brief, string $exclude_key = '' ): array { |
| 279 |
$all = array_values( self::all() ); |
| 280 |
$categories = self::infer_categories( $brief ); |
| 281 |
|
| 282 |
// Prefer blueprints tagged with an inferred category; fall back to all. |
| 283 |
$candidates = []; |
| 284 |
if ( ! empty( $categories ) ) { |
| 285 |
foreach ( $all as $bp ) { |
| 286 |
if ( array_intersect( $categories, $bp['tags'] ?? [] ) ) { |
| 287 |
$candidates[] = $bp; |
| 288 |
} |
| 289 |
} |
| 290 |
} |
| 291 |
if ( empty( $candidates ) ) { |
| 292 |
$candidates = $all; |
| 293 |
} |
| 294 |
|
| 295 |
// Avoid repeating the previous blueprint when there is an alternative. |
| 296 |
if ( '' !== $exclude_key && count( $candidates ) > 1 ) { |
| 297 |
$filtered = array_values( array_filter( |
| 298 |
$candidates, |
| 299 |
static function ( $bp ) use ( $exclude_key ) { |
| 300 |
return ( $bp['key'] ?? '' ) !== $exclude_key; |
| 301 |
} |
| 302 |
) ); |
| 303 |
if ( ! empty( $filtered ) ) { |
| 304 |
$candidates = $filtered; |
| 305 |
} |
| 306 |
} |
| 307 |
|
| 308 |
return self::weighted_pick( $candidates ); |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* Pick a colour palette for a brief, biased toward the inferred category. |
| 313 |
* |
| 314 |
* @return array{primary: string, mood: string} |
| 315 |
*/ |
| 316 |
public static function pick_palette( string $brief ): array { |
| 317 |
$categories = self::infer_categories( $brief ); |
| 318 |
$all = self::palettes(); |
| 319 |
|
| 320 |
$candidates = []; |
| 321 |
if ( ! empty( $categories ) ) { |
| 322 |
foreach ( $all as $palette ) { |
| 323 |
if ( array_intersect( $categories, $palette['tags'] ) ) { |
| 324 |
$candidates[] = $palette; |
| 325 |
} |
| 326 |
} |
| 327 |
} |
| 328 |
if ( empty( $candidates ) ) { |
| 329 |
$candidates = $all; |
| 330 |
} |
| 331 |
|
| 332 |
$chosen = $candidates[ self::rand_index( count( $candidates ) ) ]; |
| 333 |
return [ 'primary' => $chosen['primary'], 'mood' => $chosen['mood'] ]; |
| 334 |
} |
| 335 |
|
| 336 |
/** |
| 337 |
* The element types to suggest for one blueprint column. |
| 338 |
* |
| 339 |
* The column's free `suggested` list, plus its `pro` list when Pro is active. |
| 340 |
* Pro types are appended rather than interleaved so the free composition stays |
| 341 |
* the backbone of the page and the Pro widgets read as additions to it — a |
| 342 |
* campaign whose donation column is all Donors Wall and no donate button is a |
| 343 |
* worse page, not a more premium one. |
| 344 |
* |
| 345 |
* `$pro_enabled` is the authority on entitlement — this method does not |
| 346 |
* re-read the filter, so the caller's answer and this one can never disagree. |
| 347 |
* The only extra check is registration: a `pro` entry naming a type no longer |
| 348 |
* in the registry (a blueprint added through the filter, say) must not be |
| 349 |
* named to the model as something it may use. |
| 350 |
* |
| 351 |
* @param array $column A blueprint column. |
| 352 |
* @param bool $pro_enabled Whether Pro is active. |
| 353 |
* @return array<int, string> |
| 354 |
*/ |
| 355 |
public static function suggested_for( array $column, bool $pro_enabled ): array { |
| 356 |
$suggested = array_values( (array) ( $column['suggested'] ?? [] ) ); |
| 357 |
|
| 358 |
if ( ! $pro_enabled ) { |
| 359 |
return $suggested; |
| 360 |
} |
| 361 |
|
| 362 |
foreach ( (array) ( $column['pro'] ?? [] ) as $type ) { |
| 363 |
$type = (string) $type; |
| 364 |
if ( '' !== $type && CampaignSchema::is_element_type( $type ) && ! in_array( $type, $suggested, true ) ) { |
| 365 |
$suggested[] = $type; |
| 366 |
} |
| 367 |
} |
| 368 |
|
| 369 |
return $suggested; |
| 370 |
} |
| 371 |
|
| 372 |
/** |
| 373 |
* The trusted column structure for enforcement — only id/label/width (the |
| 374 |
* role/suggested/pro hints are for the prompt, not the stored layout). |
| 375 |
* |
| 376 |
* @param array $blueprint |
| 377 |
* @return array{preset: string, columns: array} |
| 378 |
*/ |
| 379 |
public static function structure( array $blueprint ): array { |
| 380 |
$columns = []; |
| 381 |
foreach ( $blueprint['columns'] ?? [] as $col ) { |
| 382 |
$columns[] = [ |
| 383 |
'id' => $col['id'] ?? '', |
| 384 |
'label' => $col['label'] ?? '', |
| 385 |
'width' => $col['width'] ?? '100%', |
| 386 |
]; |
| 387 |
} |
| 388 |
return [ |
| 389 |
'preset' => $blueprint['preset'] ?? '1-column', |
| 390 |
'columns' => $columns, |
| 391 |
]; |
| 392 |
} |
| 393 |
|
| 394 |
// ------------------------------------------------------------------ helpers |
| 395 |
|
| 396 |
/** |
| 397 |
* Weighted random pick from a list of blueprints. |
| 398 |
* |
| 399 |
* @param array<int, array> $candidates |
| 400 |
* @return array |
| 401 |
*/ |
| 402 |
private static function weighted_pick( array $candidates ): array { |
| 403 |
$total = 0; |
| 404 |
foreach ( $candidates as $bp ) { |
| 405 |
$total += max( 1, (int) ( $bp['weight'] ?? 1 ) ); |
| 406 |
} |
| 407 |
$roll = self::rand_int( 1, $total ); |
| 408 |
$acc = 0; |
| 409 |
foreach ( $candidates as $bp ) { |
| 410 |
$acc += max( 1, (int) ( $bp['weight'] ?? 1 ) ); |
| 411 |
if ( $roll <= $acc ) { |
| 412 |
return $bp; |
| 413 |
} |
| 414 |
} |
| 415 |
return $candidates[0]; |
| 416 |
} |
| 417 |
|
| 418 |
private static function rand_int( int $min, int $max ): int { |
| 419 |
if ( $max <= $min ) { |
| 420 |
return $min; |
| 421 |
} |
| 422 |
return function_exists( 'wp_rand' ) ? wp_rand( $min, $max ) : mt_rand( $min, $max ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand |
| 423 |
} |
| 424 |
|
| 425 |
private static function rand_index( int $count ): int { |
| 426 |
return $count > 0 ? self::rand_int( 0, $count - 1 ) : 0; |
| 427 |
} |
| 428 |
} |
| 429 |
|