| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* Where a post's FAQ question/answer pairs actually live. |
| 5 |
* |
| 6 |
* Four surfaces can hold them — the `thinkrank/faq` block, and the Elementor |
| 7 |
* widget, Bricks element and Beaver module that mirror it — and each stores its |
| 8 |
* repeater in a different place, in a different shape, behind a differently |
| 9 |
* spelled schema toggle. `Schema_Graph` grew a walker per surface so it could |
| 10 |
* merge them into one FAQPage. |
| 11 |
* |
| 12 |
* Nothing else could reach them. Adding the `get-faq` / `update-faq` abilities |
| 13 |
* (#767) meant either a second copy of all four walkers, which is exactly the |
| 14 |
* drift the FAQ surfaces have already produced twice, or one reader both sides |
| 15 |
* share. This is that reader: `Schema_Graph` asks it where the pairs are and |
| 16 |
* turns them into Question entities, and the abilities ask it the same question |
| 17 |
* and report them to an agent. |
| 18 |
* |
| 19 |
* It answers what is *stored*, not what is *published*. The schema toggle is |
| 20 |
* reported rather than applied, because a block with schema switched off is |
| 21 |
* still visible FAQ content that a caller needs to know about; the password |
| 22 |
* gate is left to `Schema_Graph`, because an editor asking what is on a post is |
| 23 |
* not the same question as what a visitor may be shown. |
| 24 |
* |
| 25 |
* @package ThinkRank |
| 26 |
* @subpackage SEO |
| 27 |
* @since 2.10.1 |
| 28 |
*/ |
| 29 |
|
| 30 |
declare(strict_types=1); |
| 31 |
|
| 32 |
namespace ThinkRank\SEO; |
| 33 |
|
| 34 |
// Prevent direct access |
| 35 |
if (!defined('ABSPATH')) { |
| 36 |
exit; |
| 37 |
} |
| 38 |
|
| 39 |
/** |
| 40 |
* Reads FAQ pairs out of every surface that can hold them. |
| 41 |
* |
| 42 |
* @since 2.10.1 |
| 43 |
*/ |
| 44 |
class FAQ_Content { |
| 45 |
|
| 46 |
/** |
| 47 |
* The Gutenberg block. |
| 48 |
* |
| 49 |
* @var string |
| 50 |
*/ |
| 51 |
public const SOURCE_BLOCK = 'block'; |
| 52 |
|
| 53 |
/** |
| 54 |
* The Elementor widget. |
| 55 |
* |
| 56 |
* @var string |
| 57 |
*/ |
| 58 |
public const SOURCE_ELEMENTOR = 'elementor'; |
| 59 |
|
| 60 |
/** |
| 61 |
* The Bricks element. |
| 62 |
* |
| 63 |
* @var string |
| 64 |
*/ |
| 65 |
public const SOURCE_BRICKS = 'bricks'; |
| 66 |
|
| 67 |
/** |
| 68 |
* The Beaver Builder module. |
| 69 |
* |
| 70 |
* @var string |
| 71 |
*/ |
| 72 |
public const SOURCE_BEAVER = 'beaver'; |
| 73 |
|
| 74 |
/** |
| 75 |
* Oxygen, in every generation before Oxygen 6. |
| 76 |
* |
| 77 |
* A builder name rather than a SOURCE_*: ThinkRank ships no Oxygen FAQ |
| 78 |
* module, so nothing is ever read out of an Oxygen tree and no item can |
| 79 |
* carry this as its `source`. It exists because `builder()` has to be able |
| 80 |
* to say "Oxygen renders this post" even though the FAQ reader cannot |
| 81 |
* follow it there (#831). |
| 82 |
* |
| 83 |
* @since 2.14.0 |
| 84 |
* @var string |
| 85 |
*/ |
| 86 |
public const BUILDER_OXYGEN = 'oxygen'; |
| 87 |
|
| 88 |
/** |
| 89 |
* Breakdance, which is also what Oxygen 6 is built on. |
| 90 |
* |
| 91 |
* Named separately from Oxygen because the two are sold as different |
| 92 |
* products and `get-post-content` already reports them apart; a caller |
| 93 |
* told "Breakdance" should not have to know it is looking at Oxygen 6. |
| 94 |
* |
| 95 |
* @since 2.14.0 |
| 96 |
* @var string |
| 97 |
*/ |
| 98 |
public const BUILDER_BREAKDANCE = 'breakdance'; |
| 99 |
|
| 100 |
/** |
| 101 |
* Builder post meta keys whose presence alone names the builder. |
| 102 |
* |
| 103 |
* Every key here is one of {@see Builder_Content::builder_meta_keys()}, and |
| 104 |
* the labels match the ones `get-post-content` reports for the same keys so |
| 105 |
* the two abilities cannot disagree about the same page. Oxygen spans four |
| 106 |
* of them: Oxygen 6 is Breakdance under the hood, classic 4.x writes a JSON |
| 107 |
* tree beside its shortcodes, and 4.8.3 prefixed every `ct_*` key with an |
| 108 |
* underscore. |
| 109 |
* |
| 110 |
* `FaqAbilitiesTest` asserts that every key `Builder_Content` resolves |
| 111 |
* through appears either here or in {@see self::FLAGGED_BUILDER_META_KEYS}, |
| 112 |
* so teaching `Builder_Content` about a new builder fails the build until |
| 113 |
* the FAQ abilities have been told what to do with it. |
| 114 |
* |
| 115 |
* @since 2.14.0 |
| 116 |
* @var array<string, string> |
| 117 |
*/ |
| 118 |
private const META_BUILDERS = [ |
| 119 |
'_breakdance_data' => self::BUILDER_BREAKDANCE, |
| 120 |
'_oxygen_data' => self::BUILDER_OXYGEN, |
| 121 |
'_ct_builder_json' => self::BUILDER_OXYGEN, |
| 122 |
'ct_builder_json' => self::BUILDER_OXYGEN, |
| 123 |
'_ct_builder_shortcodes' => self::BUILDER_OXYGEN, |
| 124 |
'ct_builder_shortcodes' => self::BUILDER_OXYGEN, |
| 125 |
]; |
| 126 |
|
| 127 |
/** |
| 128 |
* Builder meta keys that must NOT be read as "this builder renders the post". |
| 129 |
* |
| 130 |
* Elementor and Beaver Builder both keep stored data on a post they no |
| 131 |
* longer render: `_elementor_data` survives switching a page back to the |
| 132 |
* block editor, and `_fl_builder_draft` holds changes that were never |
| 133 |
* published. Each has its own editor flag, which `builder()` tests instead, |
| 134 |
* and reading the data key would take `writable` away from a post the block |
| 135 |
* editor really does render. |
| 136 |
* |
| 137 |
* @since 2.14.0 |
| 138 |
* @var string[] |
| 139 |
*/ |
| 140 |
private const FLAGGED_BUILDER_META_KEYS = [ |
| 141 |
'_elementor_data', |
| 142 |
'_fl_builder_data', |
| 143 |
'_fl_builder_draft', |
| 144 |
]; |
| 145 |
|
| 146 |
/** |
| 147 |
* The schema setting that turns builder accordions into FAQPage content. |
| 148 |
* |
| 149 |
* @since 2.14.0 |
| 150 |
* @var string |
| 151 |
*/ |
| 152 |
public const ACCORDION_SETTING = 'enable_accordion_faq_schema'; |
| 153 |
|
| 154 |
/** |
| 155 |
* Resolved value of {@see self::ACCORDION_SETTING} for this request. |
| 156 |
* |
| 157 |
* @since 2.14.0 |
| 158 |
* @var bool|null |
| 159 |
*/ |
| 160 |
private static ?bool $accordion_schema = null; |
| 161 |
|
| 162 |
/** |
| 163 |
* Element-name fragments that mark a subtree as an accordion. |
| 164 |
* |
| 165 |
* Matched as a fragment of the element's own name because each builder |
| 166 |
* spells it differently and renames it between releases: Oxygen classic has |
| 167 |
* `oxy_pro_accordion`, Breakdance `EssentialElements\Accordion`, and both |
| 168 |
* ship toggle variants. Matching the fragment survives a rename that an |
| 169 |
* exact list would not, and the cost of a false positive is bounded — a |
| 170 |
* subtree only contributes if title/body pairs are actually found in it. |
| 171 |
* |
| 172 |
* @since 2.14.0 |
| 173 |
* @var string[] |
| 174 |
*/ |
| 175 |
private const ACCORDION_NAME_FRAGMENTS = ['accordion', 'toggle', 'faq']; |
| 176 |
|
| 177 |
/** |
| 178 |
* Tree keys that hold an element's own name or type. |
| 179 |
* |
| 180 |
* @since 2.14.0 |
| 181 |
* @var string[] |
| 182 |
*/ |
| 183 |
private const NODE_NAME_KEYS = ['name', 'type', 'tag', 'slug', 'element', 'widgettype', 'widget_type', 'eltype']; |
| 184 |
|
| 185 |
/** |
| 186 |
* Key fragments whose string value reads as a question. |
| 187 |
* |
| 188 |
* Fragments rather than exact keys, for the same reason as the element |
| 189 |
* names: Oxygen prefixes a composite element's fields with its own slug, so |
| 190 |
* the title of an accordion item is `pro_accordion_item_title` on one |
| 191 |
* release and something adjacent on the next. |
| 192 |
* |
| 193 |
* @since 2.14.0 |
| 194 |
* @var string[] |
| 195 |
*/ |
| 196 |
private const QUESTION_KEY_FRAGMENTS = ['question', 'title', 'heading', 'header', 'label', 'tab']; |
| 197 |
|
| 198 |
/** |
| 199 |
* Key fragments whose string value reads as an answer. |
| 200 |
* |
| 201 |
* `title` is deliberately absent and `text` deliberately present. Both |
| 202 |
* builders keep an item's answer in a child element whose copy is at |
| 203 |
* `content.content.text`, and an item that keeps the two together is read |
| 204 |
* the same way. |
| 205 |
* |
| 206 |
* @since 2.14.0 |
| 207 |
* @var string[] |
| 208 |
*/ |
| 209 |
private const ANSWER_KEY_FRAGMENTS = ['answer', 'text', 'content', 'body', 'description', 'editor', 'html']; |
| 210 |
|
| 211 |
/** |
| 212 |
* How deep a builder tree is walked before the walk gives up. |
| 213 |
* |
| 214 |
* Builder trees are a few dozen levels at worst. A bound keeps a corrupt or |
| 215 |
* self-referential stored tree from exhausting the stack during a page |
| 216 |
* render, which is the kind of failure that takes a whole site down rather |
| 217 |
* than one FAQ. |
| 218 |
* |
| 219 |
* @since 2.14.0 |
| 220 |
* @var int |
| 221 |
*/ |
| 222 |
private const MAX_TREE_DEPTH = 64; |
| 223 |
|
| 224 |
/** |
| 225 |
* Gutenberg FAQ block name. |
| 226 |
* |
| 227 |
* @var string |
| 228 |
*/ |
| 229 |
public const FAQ_BLOCK = 'thinkrank/faq'; |
| 230 |
|
| 231 |
/** |
| 232 |
* Elementor FAQ widget name. |
| 233 |
* |
| 234 |
* @var string |
| 235 |
*/ |
| 236 |
public const FAQ_WIDGET = 'thinkrank-faq'; |
| 237 |
|
| 238 |
/** |
| 239 |
* Bricks FAQ element name. |
| 240 |
* |
| 241 |
* @var string |
| 242 |
*/ |
| 243 |
public const FAQ_BRICKS_ELEMENT = 'thinkrank-faq'; |
| 244 |
|
| 245 |
/** |
| 246 |
* The Beaver Builder FAQ module's slug, as stored in its layout nodes. |
| 247 |
* |
| 248 |
* Matches `ThinkRank_Beaver_FAQ_Module::SLUG`. Duplicated as a literal |
| 249 |
* rather than referenced, because that class extends `FLBuilderModule` and |
| 250 |
* so cannot be loaded at all when Beaver Builder is inactive — which is |
| 251 |
* exactly the site that still has a stored layout, after a builder switch. |
| 252 |
* |
| 253 |
* @var string |
| 254 |
*/ |
| 255 |
public const FAQ_BEAVER_MODULE = 'thinkrank-faq'; |
| 256 |
|
| 257 |
/** |
| 258 |
* Every FAQ producer found on a post, in collection order. |
| 259 |
* |
| 260 |
* @since 2.10.1 |
| 261 |
* @param \WP_Post $post Post to read. |
| 262 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 263 |
*/ |
| 264 |
public static function groups(\WP_Post $post): array { |
| 265 |
$groups = []; |
| 266 |
|
| 267 |
// A Bricks page throws `post_content` away, so a FAQ block left there |
| 268 |
// when the page was switched over never renders. Reporting its |
| 269 |
// questions would describe content no visitor can see, which Google |
| 270 |
// treats as a violation rather than merely a duplicate (#650). |
| 271 |
self::load_builder_content(); |
| 272 |
|
| 273 |
if (!Builder_Content::bricks_supersedes_post_content((int) $post->ID)) { |
| 274 |
$groups = array_merge($groups, self::block_groups($post)); |
| 275 |
} |
| 276 |
|
| 277 |
$groups = array_merge($groups, self::elementor_groups($post)); |
| 278 |
$groups = array_merge($groups, self::bricks_groups($post)); |
| 279 |
$groups = array_merge($groups, self::beaver_groups($post)); |
| 280 |
$groups = array_merge($groups, self::accordion_groups($post)); |
| 281 |
|
| 282 |
return $groups; |
| 283 |
} |
| 284 |
|
| 285 |
/** |
| 286 |
* Whether page-builder accordions may contribute to the FAQPage. |
| 287 |
* |
| 288 |
* Off unless the site says otherwise, and that default is the whole point. |
| 289 |
* The other four producers are ThinkRank's own FAQ surfaces: an author who |
| 290 |
* dropped one on the page asked for FAQ markup, and the block even carries a |
| 291 |
* per-instance toggle. An Oxygen accordion carries no such intent — it may |
| 292 |
* hold questions, or product specifications, or a changelog — so reading one |
| 293 |
* as a FAQPage is the site owner's call to make. Defaulting it on would |
| 294 |
* change what existing sites publish on upgrade, unasked. |
| 295 |
* |
| 296 |
* Reported either way, because the abilities describe what is on a page |
| 297 |
* rather than what is published: with the setting off the questions are |
| 298 |
* still returned by `get-faq`, and `Schema_Graph` skips the group exactly as |
| 299 |
* it skips a block whose own schema toggle is off. |
| 300 |
* |
| 301 |
* Resolved once per request. The switch is site-wide so it cannot change |
| 302 |
* mid-request, and `Schema_Management_System` builds a cache manager and |
| 303 |
* registers listeners in its constructor, which is not something to spin up |
| 304 |
* per accordion. |
| 305 |
* |
| 306 |
* @since 2.14.0 |
| 307 |
* @return bool |
| 308 |
*/ |
| 309 |
public static function accordion_schema_enabled(): bool { |
| 310 |
if (null === self::$accordion_schema) { |
| 311 |
self::$accordion_schema = false; |
| 312 |
|
| 313 |
if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) { |
| 314 |
$settings = (new Schema_Management_System())->get_settings('site', null); |
| 315 |
|
| 316 |
// Absent means off here, unlike the schema master switch: |
| 317 |
// this publishes markup a site was not publishing before, so |
| 318 |
// it has to be asked for rather than merely not refused. |
| 319 |
self::$accordion_schema = !empty($settings[self::ACCORDION_SETTING]); |
| 320 |
} |
| 321 |
} |
| 322 |
|
| 323 |
/** |
| 324 |
* Filter whether a builder accordion contributes to the FAQPage. |
| 325 |
* |
| 326 |
* The stored switch is site-wide, which is the right grain for "are our |
| 327 |
* accordions FAQs?" but the wrong one for a site where some are and |
| 328 |
* some are not. This runs on every call rather than being memoized with |
| 329 |
* the stored read, so it can answer per post. |
| 330 |
* |
| 331 |
* @since 2.14.0 |
| 332 |
* |
| 333 |
* @param bool $enabled Whether accordion questions reach the FAQPage. |
| 334 |
*/ |
| 335 |
return (bool) apply_filters('thinkrank_faq_accordion_schema', self::$accordion_schema); |
| 336 |
} |
| 337 |
|
| 338 |
/** |
| 339 |
* Discard the resolved accordion switch. Test seam. |
| 340 |
* |
| 341 |
* @since 2.14.0 |
| 342 |
* @return void |
| 343 |
*/ |
| 344 |
public static function flush_accordion_schema_cache(): void { |
| 345 |
self::$accordion_schema = null; |
| 346 |
} |
| 347 |
|
| 348 |
/** |
| 349 |
* Every question/answer pair on a post, flattened and normalised. |
| 350 |
* |
| 351 |
* Rows with no question or no answer are dropped: they are a half-filled |
| 352 |
* repeater row in the editor, not an FAQ entry, and reporting them as one |
| 353 |
* would have an agent "fixing" content the author is still writing. |
| 354 |
* |
| 355 |
* @since 2.10.1 |
| 356 |
* @param \WP_Post $post Post to read. |
| 357 |
* @return array<int, array{question: string, answer: string, source: string, schema_enabled: bool, image_id: int, image_url: string, image_alt: string}> |
| 358 |
*/ |
| 359 |
public static function items(\WP_Post $post): array { |
| 360 |
$items = []; |
| 361 |
|
| 362 |
foreach (self::groups($post) as $group) { |
| 363 |
foreach (self::rows($group['pairs']) as $row) { |
| 364 |
$question = trim(wp_strip_all_tags((string) ($row['question'] ?? ''))); |
| 365 |
$answer = trim((string) ($row['answer'] ?? '')); |
| 366 |
|
| 367 |
if ('' === $question || '' === $answer) { |
| 368 |
continue; |
| 369 |
} |
| 370 |
|
| 371 |
$items[] = [ |
| 372 |
'question' => $question, |
| 373 |
'answer' => $answer, |
| 374 |
'source' => $group['source'], |
| 375 |
'schema_enabled' => $group['schema'], |
| 376 |
'image_id' => (int) ($row['imageId'] ?? $row['image_id'] ?? 0), |
| 377 |
'image_url' => (string) ($row['imageUrl'] ?? $row['image_url'] ?? ''), |
| 378 |
'image_alt' => (string) ($row['imageAlt'] ?? $row['image_alt'] ?? ''), |
| 379 |
]; |
| 380 |
} |
| 381 |
} |
| 382 |
|
| 383 |
return $items; |
| 384 |
} |
| 385 |
|
| 386 |
/** |
| 387 |
* Which page builder renders this post, if any. |
| 388 |
* |
| 389 |
* Each test is the builder's own: Elementor stores `builder` in |
| 390 |
* `_elementor_edit_mode` for a page it owns, Beaver Builder flags |
| 391 |
* `_fl_builder_enabled`, and Bricks is asked through the resolver that |
| 392 |
* already knows when it supersedes `post_content`. |
| 393 |
* |
| 394 |
* Oxygen and Breakdance have no such flag, so they are recognised by their |
| 395 |
* stored tree instead. They were missing entirely, and because '' is also |
| 396 |
* what the block editor returns, an Oxygen page was reported as having no |
| 397 |
* builder at all: `get-faq` called it writable and `update-faq` stored a |
| 398 |
* block Oxygen never renders, which is the one outcome both were written to |
| 399 |
* prevent (#831). The keys come from `Builder_Content`, which has detected |
| 400 |
* both since 1.23.0 for scoring, so nothing new has to be learned here. |
| 401 |
* |
| 402 |
* @since 2.10.1 |
| 403 |
* @since 2.14.0 Detects Oxygen and Breakdance. |
| 404 |
* @param int $post_id Post ID. |
| 405 |
* @return string One of {@see self::builders()}, or '' for the block editor. |
| 406 |
*/ |
| 407 |
public static function builder(int $post_id): string { |
| 408 |
self::load_builder_content(); |
| 409 |
|
| 410 |
if ('builder' === (string) get_post_meta($post_id, '_elementor_edit_mode', true)) { |
| 411 |
return self::SOURCE_ELEMENTOR; |
| 412 |
} |
| 413 |
|
| 414 |
if (Builder_Content::bricks_supersedes_post_content($post_id)) { |
| 415 |
return self::SOURCE_BRICKS; |
| 416 |
} |
| 417 |
|
| 418 |
if (!empty(get_post_meta($post_id, '_fl_builder_enabled', true))) { |
| 419 |
return self::SOURCE_BEAVER; |
| 420 |
} |
| 421 |
|
| 422 |
return self::builder_from_meta($post_id); |
| 423 |
} |
| 424 |
|
| 425 |
/** |
| 426 |
* Every builder name {@see self::builder()} can report. |
| 427 |
* |
| 428 |
* Derived rather than listed, so an ability's enum cannot fall behind the |
| 429 |
* detection: the three flagged builders, then whatever `META_BUILDERS` |
| 430 |
* names, deduplicated because several keys share one builder. |
| 431 |
* |
| 432 |
* @since 2.14.0 |
| 433 |
* @return string[] |
| 434 |
*/ |
| 435 |
public static function builders(): array { |
| 436 |
return array_values(array_unique(array_merge( |
| 437 |
[self::SOURCE_ELEMENTOR, self::SOURCE_BRICKS, self::SOURCE_BEAVER], |
| 438 |
array_values(self::META_BUILDERS) |
| 439 |
))); |
| 440 |
} |
| 441 |
|
| 442 |
/** |
| 443 |
* The builders ThinkRank can read FAQ questions out of. |
| 444 |
* |
| 445 |
* Oxygen and Breakdance are read through their own accordion rather than |
| 446 |
* through a ThinkRank module, so they are readable without being writable |
| 447 |
* and without appearing in {@see self::module_builders()}. |
| 448 |
* |
| 449 |
* @since 2.14.0 |
| 450 |
* @return string[] |
| 451 |
*/ |
| 452 |
public static function readable_builders(): array { |
| 453 |
return array_merge( |
| 454 |
self::module_builders(), |
| 455 |
[self::BUILDER_OXYGEN, self::BUILDER_BREAKDANCE] |
| 456 |
); |
| 457 |
} |
| 458 |
|
| 459 |
/** |
| 460 |
* The builders that have a ThinkRank FAQ module of their own. |
| 461 |
* |
| 462 |
* The distinction an agent needs, and the one the first fix did not have. |
| 463 |
* A post built with one of these is refused by `update-faq`, but there is |
| 464 |
* somewhere to send its author: the ThinkRank FAQ module for that builder, |
| 465 |
* which carries its own schema toggle. A readable builder outside this list |
| 466 |
* has no module to point at, so its author is sent to the builder's own |
| 467 |
* accordion instead. |
| 468 |
* |
| 469 |
* @since 2.14.0 |
| 470 |
* @return string[] |
| 471 |
*/ |
| 472 |
public static function module_builders(): array { |
| 473 |
return [self::SOURCE_ELEMENTOR, self::SOURCE_BRICKS, self::SOURCE_BEAVER]; |
| 474 |
} |
| 475 |
|
| 476 |
/** |
| 477 |
* Whether ThinkRank can read FAQ questions out of a builder's storage. |
| 478 |
* |
| 479 |
* @since 2.14.0 |
| 480 |
* @param string $builder Builder name, or '' for the block editor. |
| 481 |
* @return bool True for the block editor and every builder with a reader. |
| 482 |
*/ |
| 483 |
public static function builder_is_readable(string $builder): bool { |
| 484 |
return '' === $builder || in_array($builder, self::readable_builders(), true); |
| 485 |
} |
| 486 |
|
| 487 |
/** |
| 488 |
* Whether a builder has a ThinkRank FAQ module an author can be sent to. |
| 489 |
* |
| 490 |
* @since 2.14.0 |
| 491 |
* @param string $builder Builder name, or '' for the block editor. |
| 492 |
* @return bool |
| 493 |
*/ |
| 494 |
public static function builder_has_module(string $builder): bool { |
| 495 |
return in_array($builder, self::module_builders(), true); |
| 496 |
} |
| 497 |
|
| 498 |
/** |
| 499 |
* Builder meta keys this class has an answer for. Test seam. |
| 500 |
* |
| 501 |
* The drift guard in `FaqAbilitiesTest` compares this against |
| 502 |
* {@see Builder_Content::builder_meta_keys()}: a key in neither set is a |
| 503 |
* builder whose pages would silently be reported as writable. |
| 504 |
* |
| 505 |
* @since 2.14.0 |
| 506 |
* @return string[] |
| 507 |
*/ |
| 508 |
public static function classified_builder_meta_keys(): array { |
| 509 |
return array_merge(array_keys(self::META_BUILDERS), self::FLAGGED_BUILDER_META_KEYS); |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* The builder named by a post's stored tree, for builders with no flag. |
| 514 |
* |
| 515 |
* Presence is the whole test, so the value is checked for emptiness in both |
| 516 |
* shapes it arrives in: Breakdance and Oxygen classic 4.x store JSON |
| 517 |
* strings, while a filtered or already-decoded value can be an array. |
| 518 |
* |
| 519 |
* @since 2.14.0 |
| 520 |
* @param int $post_id Post ID. |
| 521 |
* @return string Builder name, or '' when no builder tree is stored. |
| 522 |
*/ |
| 523 |
private static function builder_from_meta(int $post_id): string { |
| 524 |
foreach (self::META_BUILDERS as $meta_key => $builder) { |
| 525 |
$stored = get_post_meta($post_id, $meta_key, true); |
| 526 |
|
| 527 |
if (is_array($stored)) { |
| 528 |
if ([] !== $stored) { |
| 529 |
return $builder; |
| 530 |
} |
| 531 |
|
| 532 |
continue; |
| 533 |
} |
| 534 |
|
| 535 |
if (is_string($stored) && '' !== trim($stored)) { |
| 536 |
return $builder; |
| 537 |
} |
| 538 |
} |
| 539 |
|
| 540 |
return ''; |
| 541 |
} |
| 542 |
|
| 543 |
/** |
| 544 |
* Load Builder_Content, which resolves the Bricks half of the answer. |
| 545 |
* |
| 546 |
* Required rather than autoloaded for the same reason Schema_Graph used to |
| 547 |
* require it: this runs in contexts where the plugin autoloader is not |
| 548 |
* guaranteed to be registered. |
| 549 |
* |
| 550 |
* @return void |
| 551 |
*/ |
| 552 |
private static function load_builder_content(): void { |
| 553 |
if (class_exists('ThinkRank\\SEO\\Builder_Content')) { |
| 554 |
return; |
| 555 |
} |
| 556 |
|
| 557 |
$file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php'; |
| 558 |
if (file_exists($file)) { |
| 559 |
require_once $file; |
| 560 |
} |
| 561 |
} |
| 562 |
|
| 563 |
/** |
| 564 |
* FAQ blocks in a post's content, including nested ones. |
| 565 |
* |
| 566 |
* @param \WP_Post $post Post to read. |
| 567 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 568 |
*/ |
| 569 |
private static function block_groups(\WP_Post $post): array { |
| 570 |
if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) { |
| 571 |
return []; |
| 572 |
} |
| 573 |
|
| 574 |
return self::walk_blocks(parse_blocks($post->post_content)); |
| 575 |
} |
| 576 |
|
| 577 |
/** |
| 578 |
* Recurse a parsed block tree. |
| 579 |
* |
| 580 |
* @param array $blocks Parsed blocks. |
| 581 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 582 |
*/ |
| 583 |
private static function walk_blocks(array $blocks): array { |
| 584 |
$groups = []; |
| 585 |
|
| 586 |
foreach ($blocks as $block) { |
| 587 |
if (!is_array($block)) { |
| 588 |
continue; |
| 589 |
} |
| 590 |
|
| 591 |
$block_name = (string) ($block['blockName'] ?? ''); |
| 592 |
$attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : []; |
| 593 |
|
| 594 |
// A leftover Rank Math FAQ block is absorbed as if it were ours, so |
| 595 |
// an unmigrated post contributes its questions to the single |
| 596 |
// FAQPage rather than to nothing at all (#777). The fallback stays |
| 597 |
// silent while Rank Math is active and still emitting its own. |
| 598 |
if (\ThinkRank\Integrations\Rank_Math_Blocks::is_source_block($block_name)) { |
| 599 |
$fallback = \ThinkRank\Integrations\Rank_Math_Blocks::schema_fallback($block_name, $attrs); |
| 600 |
if (null !== $fallback) { |
| 601 |
$block_name = $fallback['name']; |
| 602 |
$attrs = $fallback['attrs']; |
| 603 |
} |
| 604 |
} |
| 605 |
|
| 606 |
if ($block_name === self::FAQ_BLOCK) { |
| 607 |
$groups[] = [ |
| 608 |
'source' => self::SOURCE_BLOCK, |
| 609 |
// Mirrors Blocks_Manager: schema is on unless explicitly disabled. |
| 610 |
'schema' => !(array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']), |
| 611 |
'pairs' => self::rows($attrs['faqs'] ?? []), |
| 612 |
]; |
| 613 |
} |
| 614 |
|
| 615 |
if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) { |
| 616 |
$groups = array_merge($groups, self::walk_blocks($block['innerBlocks'])); |
| 617 |
} |
| 618 |
} |
| 619 |
|
| 620 |
return $groups; |
| 621 |
} |
| 622 |
|
| 623 |
/** |
| 624 |
* Question/answer pairs from an Oxygen or Breakdance accordion. |
| 625 |
* |
| 626 |
* ThinkRank ships no FAQ module for either builder, so unlike the other four |
| 627 |
* producers there is no element of ours to look for. What there is instead is |
| 628 |
* the builder's own accordion, which is visible FAQ content that was being |
| 629 |
* reported as nothing at all: `get-faq` returned `total: 0` on a page with a |
| 630 |
* working FAQ on it, and the page published no FAQPage (#831). |
| 631 |
* |
| 632 |
* Read from the stored tree rather than by rendering, for the reasons |
| 633 |
* `Builder_Content` already documents: rendering an Oxygen page outside a |
| 634 |
* front-end request is slow, stateful and can fatal in admin context, while |
| 635 |
* the stored tree is cheap and side-effect free. |
| 636 |
* |
| 637 |
* The shapes are taken from the builders' own element definitions rather |
| 638 |
* than inferred: `Advanced_Accordion/element.php` and |
| 639 |
* `Accordion_Content/element.php` in `breakdance-elements` declare the |
| 640 |
* accordion as an element per item, each item holding its question at |
| 641 |
* `content.content.title` and its answer in its own child elements. Both |
| 642 |
* declare `availableIn() === ['breakdance', 'oxygen']`, so Oxygen 6 is the |
| 643 |
* same tree under a different product name. |
| 644 |
* |
| 645 |
* Oxygen classic keeps two copies of the same page, a JSON tree and a |
| 646 |
* shortcode string, and neither is reliably the richer one: a composite |
| 647 |
* element's copy is base64-encoded inside `ct_options` in the shortcode form, |
| 648 |
* while a key missing from the JSON walker loses it there. `from_oxygen_classic()` |
| 649 |
* resolves that by reading both and keeping whichever yielded more; this does |
| 650 |
* the same, keeping whichever yielded more pairs. |
| 651 |
* |
| 652 |
* @since 2.14.0 |
| 653 |
* @param \WP_Post $post Post to read. |
| 654 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 655 |
*/ |
| 656 |
private static function accordion_groups(\WP_Post $post): array { |
| 657 |
$builder = self::builder((int) $post->ID); |
| 658 |
|
| 659 |
// Only the builders with no FAQ module of their own. Elementor, Bricks |
| 660 |
// and Beaver have one, and sweeping their trees for accordions as well |
| 661 |
// would publish a second FAQPage source behind the back of the module's |
| 662 |
// own schema toggle. |
| 663 |
if (!in_array($builder, [self::BUILDER_OXYGEN, self::BUILDER_BREAKDANCE], true)) { |
| 664 |
return []; |
| 665 |
} |
| 666 |
|
| 667 |
$pairs = self::accordion_pairs((int) $post->ID); |
| 668 |
|
| 669 |
/** |
| 670 |
* Filter the question/answer pairs read out of a builder accordion. |
| 671 |
* |
| 672 |
* The shapes below cover Oxygen classic, Oxygen 6 and Breakdance as they |
| 673 |
* store an accordion today. A builder release that moves its fields, or |
| 674 |
* a third-party accordion element, can be taught here instead of waiting |
| 675 |
* for the walker to learn it. |
| 676 |
* |
| 677 |
* @since 2.14.0 |
| 678 |
* |
| 679 |
* @param array<int, array{question: string, answer: string}> $pairs Pairs found. |
| 680 |
* @param \WP_Post $post Post being read. |
| 681 |
* @param string $builder Builder that renders it. |
| 682 |
*/ |
| 683 |
$pairs = apply_filters('thinkrank_faq_builder_accordions', $pairs, $post, $builder); |
| 684 |
|
| 685 |
if (!is_array($pairs) || [] === $pairs) { |
| 686 |
return []; |
| 687 |
} |
| 688 |
|
| 689 |
return [[ |
| 690 |
'source' => $builder, |
| 691 |
'schema' => self::accordion_schema_enabled(), |
| 692 |
'pairs' => self::rows($pairs), |
| 693 |
]]; |
| 694 |
} |
| 695 |
|
| 696 |
/** |
| 697 |
* Accordion pairs from whichever storage this post has. |
| 698 |
* |
| 699 |
* @since 2.14.0 |
| 700 |
* @param int $post_id Post ID. |
| 701 |
* @return array<int, array{question: string, answer: string}> |
| 702 |
*/ |
| 703 |
private static function accordion_pairs(int $post_id): array { |
| 704 |
$best = []; |
| 705 |
|
| 706 |
foreach (self::classified_builder_meta_keys() as $meta_key) { |
| 707 |
if (in_array($meta_key, self::FLAGGED_BUILDER_META_KEYS, true)) { |
| 708 |
continue; // Elementor and Beaver, which have their own module. |
| 709 |
} |
| 710 |
|
| 711 |
$stored = get_post_meta($post_id, $meta_key, true); |
| 712 |
$pairs = self::accordion_pairs_from_stored($stored); |
| 713 |
|
| 714 |
if (count($pairs) > count($best)) { |
| 715 |
$best = $pairs; |
| 716 |
} |
| 717 |
} |
| 718 |
|
| 719 |
return $best; |
| 720 |
} |
| 721 |
|
| 722 |
/** |
| 723 |
* Accordion pairs from one stored builder value, in either storage form. |
| 724 |
* |
| 725 |
* @since 2.14.0 |
| 726 |
* @param mixed $stored Raw meta value. |
| 727 |
* @return array<int, array{question: string, answer: string}> |
| 728 |
*/ |
| 729 |
private static function accordion_pairs_from_stored($stored): array { |
| 730 |
if (is_array($stored)) { |
| 731 |
return self::accordion_pairs_from_tree($stored); |
| 732 |
} |
| 733 |
|
| 734 |
if (!is_string($stored) || '' === trim($stored)) { |
| 735 |
return []; |
| 736 |
} |
| 737 |
|
| 738 |
$decoded = json_decode($stored, true); |
| 739 |
if (is_array($decoded)) { |
| 740 |
return self::accordion_pairs_from_tree($decoded); |
| 741 |
} |
| 742 |
|
| 743 |
return self::accordion_pairs_from_shortcodes($stored); |
| 744 |
} |
| 745 |
|
| 746 |
/** |
| 747 |
* Walk a decoded builder tree and collect accordion pairs. |
| 748 |
* |
| 749 |
* @since 2.14.0 |
| 750 |
* @param array<mixed> $tree Decoded tree. |
| 751 |
* @return array<int, array{question: string, answer: string}> |
| 752 |
*/ |
| 753 |
private static function accordion_pairs_from_tree(array $tree): array { |
| 754 |
$pairs = []; |
| 755 |
|
| 756 |
$walk = static function ($node, int $depth) use (&$walk, &$pairs): void { |
| 757 |
if ($depth > self::MAX_TREE_DEPTH) { |
| 758 |
return; |
| 759 |
} |
| 760 |
|
| 761 |
$node = self::as_tree_node($node); |
| 762 |
if (null === $node) { |
| 763 |
return; |
| 764 |
} |
| 765 |
|
| 766 |
if (self::node_is_accordion($node)) { |
| 767 |
$pairs = array_merge($pairs, self::pairs_in_subtree($node)); |
| 768 |
|
| 769 |
// Not descended into again: pairs_in_subtree() has already read |
| 770 |
// the whole thing, and a nested accordion would be collected |
| 771 |
// twice. |
| 772 |
return; |
| 773 |
} |
| 774 |
|
| 775 |
foreach ($node as $child) { |
| 776 |
$walk($child, $depth + 1); |
| 777 |
} |
| 778 |
}; |
| 779 |
|
| 780 |
$walk($tree, 0); |
| 781 |
|
| 782 |
return $pairs; |
| 783 |
} |
| 784 |
|
| 785 |
/** |
| 786 |
* A tree node as an array of children, unwrapping Breakdance's inner JSON. |
| 787 |
* |
| 788 |
* Breakdance stores the whole page as a JSON *string* under one key of the |
| 789 |
* outer object, so a walker that only descends arrays stops at the door. |
| 790 |
* Decoding a string that parses as a JSON object is what lets the same |
| 791 |
* walker reach Oxygen 6 content, and it is bounded to strings that look like |
| 792 |
* JSON so an answer's prose is never parsed as a tree. |
| 793 |
* |
| 794 |
* @since 2.14.0 |
| 795 |
* @param mixed $node Node to normalise. |
| 796 |
* @return array<mixed>|null Children, or null for a leaf. |
| 797 |
*/ |
| 798 |
private static function as_tree_node($node): ?array { |
| 799 |
if (is_object($node)) { |
| 800 |
$node = get_object_vars($node); |
| 801 |
} |
| 802 |
|
| 803 |
if (is_array($node)) { |
| 804 |
return $node; |
| 805 |
} |
| 806 |
|
| 807 |
if (!is_string($node)) { |
| 808 |
return null; |
| 809 |
} |
| 810 |
|
| 811 |
$trimmed = trim($node); |
| 812 |
|
| 813 |
if ('' === $trimmed || ('{' !== $trimmed[0] && '[' !== $trimmed[0])) { |
| 814 |
return null; |
| 815 |
} |
| 816 |
|
| 817 |
$decoded = json_decode($trimmed, true); |
| 818 |
|
| 819 |
return is_array($decoded) ? $decoded : null; |
| 820 |
} |
| 821 |
|
| 822 |
/** |
| 823 |
* An element's own name, wherever the builder keeps it. |
| 824 |
* |
| 825 |
* Two layouts, because the builders differ in where the name sits relative |
| 826 |
* to the children. Oxygen classic puts it on the node itself |
| 827 |
* (`{name: 'oxy_pro_accordion', children: [...]}`), while Breakdance wraps |
| 828 |
* it a level down (`{data: {type: 'EssentialElements\AdvancedAccordion'}, |
| 829 |
* children: [...]}`), so the name is a sibling of the children rather than |
| 830 |
* their parent. |
| 831 |
* |
| 832 |
* Reading only the node's own keys is what broke the first version: the |
| 833 |
* walk matched Breakdance's `data` object, which holds the type but none of |
| 834 |
* the children, and so handed an accordion with every item stripped off it |
| 835 |
* to the pair reader. A real Breakdance page yielded nothing at all. |
| 836 |
* |
| 837 |
* @since 2.14.0 |
| 838 |
* @param array<mixed> $node Tree node. |
| 839 |
* @return string Element name, or '' when the node names nothing. |
| 840 |
*/ |
| 841 |
private static function node_name(array $node): string { |
| 842 |
$name = self::name_on_node($node); |
| 843 |
|
| 844 |
if ('' !== $name) { |
| 845 |
return $name; |
| 846 |
} |
| 847 |
|
| 848 |
// Breakdance and Oxygen 6: `{id, data: {type, properties}, children}`. |
| 849 |
$data = self::as_tree_node($node['data'] ?? null); |
| 850 |
|
| 851 |
return null === $data ? '' : self::name_on_node($data); |
| 852 |
} |
| 853 |
|
| 854 |
/** |
| 855 |
* The element name carried by a node's own keys. |
| 856 |
* |
| 857 |
* @since 2.14.0 |
| 858 |
* @param array<mixed> $node Tree node. |
| 859 |
* @return string |
| 860 |
*/ |
| 861 |
private static function name_on_node(array $node): string { |
| 862 |
foreach ($node as $key => $value) { |
| 863 |
if (!is_string($key) || !is_string($value)) { |
| 864 |
continue; |
| 865 |
} |
| 866 |
|
| 867 |
if (in_array(strtolower($key), self::NODE_NAME_KEYS, true)) { |
| 868 |
return $value; |
| 869 |
} |
| 870 |
} |
| 871 |
|
| 872 |
return ''; |
| 873 |
} |
| 874 |
|
| 875 |
/** |
| 876 |
* Whether an element's name marks it as an accordion or one of its items. |
| 877 |
* |
| 878 |
* @since 2.14.0 |
| 879 |
* @param string $name Element name. |
| 880 |
* @return bool |
| 881 |
*/ |
| 882 |
private static function name_is_accordion(string $name): bool { |
| 883 |
$name = strtolower($name); |
| 884 |
|
| 885 |
foreach (self::ACCORDION_NAME_FRAGMENTS as $fragment) { |
| 886 |
if (false !== strpos($name, $fragment)) { |
| 887 |
return true; |
| 888 |
} |
| 889 |
} |
| 890 |
|
| 891 |
return false; |
| 892 |
} |
| 893 |
|
| 894 |
/** |
| 895 |
* Whether a node names itself as an accordion. |
| 896 |
* |
| 897 |
* @since 2.14.0 |
| 898 |
* @param array<mixed> $node Tree node. |
| 899 |
* @return bool |
| 900 |
*/ |
| 901 |
private static function node_is_accordion(array $node): bool { |
| 902 |
return self::name_is_accordion(self::node_name($node)); |
| 903 |
} |
| 904 |
|
| 905 |
/** |
| 906 |
* Collect question/answer pairs from inside one accordion element. |
| 907 |
* |
| 908 |
* The shape both builders actually use is an element per item rather than a |
| 909 |
* repeater: Breakdance nests an `AccordionContent` under the accordion, |
| 910 |
* carrying the question at `content.content.title`, and the answer lives in |
| 911 |
* that item's own child elements (a Text, a RichText, a Heading), each |
| 912 |
* keeping its copy at `content.content.text`. Oxygen classic arranges an |
| 913 |
* `oxy_pro_accordion_item` the same way. So an item's question is read from |
| 914 |
* the item's own properties and its answer from its children, which is what |
| 915 |
* keeps a nested element's unrelated `title` — a Video's own name, say — |
| 916 |
* from being read as the next question. |
| 917 |
* |
| 918 |
* A repeater is still handled, for a builder that stores one and for the |
| 919 |
* accordion whose items carry no element name of their own: when no named |
| 920 |
* item is found, every node is offered to the pair reader and a node |
| 921 |
* carrying both halves is taken whole. |
| 922 |
* |
| 923 |
* @since 2.14.0 |
| 924 |
* @param array<mixed> $accordion Accordion element node. |
| 925 |
* @return array<int, array{question: string, answer: string}> |
| 926 |
*/ |
| 927 |
private static function pairs_in_subtree(array $accordion): array { |
| 928 |
$items = self::item_nodes($accordion); |
| 929 |
|
| 930 |
if ([] !== $items) { |
| 931 |
$pairs = []; |
| 932 |
|
| 933 |
foreach ($items as $item) { |
| 934 |
$question = self::deep_fragment_value($item, self::QUESTION_KEY_FRAGMENTS, true); |
| 935 |
|
| 936 |
if ('' === $question) { |
| 937 |
continue; |
| 938 |
} |
| 939 |
|
| 940 |
$children = self::as_tree_node($item['children'] ?? null); |
| 941 |
$answer = null === $children |
| 942 |
? '' |
| 943 |
: self::deep_fragment_value($children, self::ANSWER_KEY_FRAGMENTS, false); |
| 944 |
|
| 945 |
// An item that keeps its answer beside its question rather than |
| 946 |
// in a child element. |
| 947 |
if ('' === $answer) { |
| 948 |
$answer = self::deep_fragment_value($item, self::ANSWER_KEY_FRAGMENTS, true); |
| 949 |
} |
| 950 |
|
| 951 |
if ('' !== $answer) { |
| 952 |
$pairs[] = [ |
| 953 |
'question' => $question, |
| 954 |
'answer' => $answer, |
| 955 |
]; |
| 956 |
} |
| 957 |
} |
| 958 |
|
| 959 |
return $pairs; |
| 960 |
} |
| 961 |
|
| 962 |
return self::repeater_pairs($accordion); |
| 963 |
} |
| 964 |
|
| 965 |
/** |
| 966 |
* The item elements directly describing one accordion's entries. |
| 967 |
* |
| 968 |
* An item names itself an accordion too (`AccordionContent`, |
| 969 |
* `oxy_pro_accordion_item`), so the accordion is told from its items by |
| 970 |
* being the node the search started at rather than by its name. |
| 971 |
* |
| 972 |
* @since 2.14.0 |
| 973 |
* @param array<mixed> $accordion Accordion element node. |
| 974 |
* @return array<int, array<mixed>> Item nodes. |
| 975 |
*/ |
| 976 |
private static function item_nodes(array $accordion): array { |
| 977 |
$items = []; |
| 978 |
|
| 979 |
$walk = static function ($node, int $depth, bool $is_root) use (&$walk, &$items): void { |
| 980 |
if ($depth > self::MAX_TREE_DEPTH) { |
| 981 |
return; |
| 982 |
} |
| 983 |
|
| 984 |
$node = self::as_tree_node($node); |
| 985 |
if (null === $node) { |
| 986 |
return; |
| 987 |
} |
| 988 |
|
| 989 |
if (!$is_root && self::node_is_accordion($node)) { |
| 990 |
$items[] = $node; |
| 991 |
|
| 992 |
// Not descended into: a nested accordion inside an item is read |
| 993 |
// when the walk reaches it on its own, and descending here |
| 994 |
// would collect its items as siblings of this one's. |
| 995 |
return; |
| 996 |
} |
| 997 |
|
| 998 |
foreach ($node as $child) { |
| 999 |
$walk($child, $depth + 1, false); |
| 1000 |
} |
| 1001 |
}; |
| 1002 |
|
| 1003 |
$walk($accordion, 0, true); |
| 1004 |
|
| 1005 |
return $items; |
| 1006 |
} |
| 1007 |
|
| 1008 |
/** |
| 1009 |
* Pair a repeater's rows, or zip loose questions and answers in order. |
| 1010 |
* |
| 1011 |
* The fallback for an accordion whose items are not elements of their own. A |
| 1012 |
* row carrying both halves pairs directly; otherwise a question is held |
| 1013 |
* until an answer follows it, and a second question arriving first replaces |
| 1014 |
* the held one rather than pairing with a later answer, so an item with no |
| 1015 |
* answer drops out instead of stealing the next item's. |
| 1016 |
* |
| 1017 |
* @since 2.14.0 |
| 1018 |
* @param array<mixed> $accordion Accordion element node. |
| 1019 |
* @return array<int, array{question: string, answer: string}> |
| 1020 |
*/ |
| 1021 |
private static function repeater_pairs(array $accordion): array { |
| 1022 |
$pairs = []; |
| 1023 |
$pending = ''; |
| 1024 |
|
| 1025 |
$walk = static function ($node, int $depth) use (&$walk, &$pairs, &$pending): void { |
| 1026 |
if ($depth > self::MAX_TREE_DEPTH) { |
| 1027 |
return; |
| 1028 |
} |
| 1029 |
|
| 1030 |
$node = self::as_tree_node($node); |
| 1031 |
if (null === $node) { |
| 1032 |
return; |
| 1033 |
} |
| 1034 |
|
| 1035 |
$question = self::first_fragment_value($node, self::QUESTION_KEY_FRAGMENTS); |
| 1036 |
$answer = self::first_fragment_value($node, self::ANSWER_KEY_FRAGMENTS); |
| 1037 |
|
| 1038 |
if ('' !== $question && '' !== $answer) { |
| 1039 |
$pairs[] = [ |
| 1040 |
'question' => $question, |
| 1041 |
'answer' => $answer, |
| 1042 |
]; |
| 1043 |
$pending = ''; |
| 1044 |
|
| 1045 |
return; |
| 1046 |
} |
| 1047 |
|
| 1048 |
if ('' !== $question) { |
| 1049 |
$pending = $question; |
| 1050 |
} elseif ('' !== $answer && '' !== $pending) { |
| 1051 |
$pairs[] = [ |
| 1052 |
'question' => $pending, |
| 1053 |
'answer' => $answer, |
| 1054 |
]; |
| 1055 |
$pending = ''; |
| 1056 |
} |
| 1057 |
|
| 1058 |
foreach ($node as $child) { |
| 1059 |
$walk($child, $depth + 1); |
| 1060 |
} |
| 1061 |
}; |
| 1062 |
|
| 1063 |
$walk($accordion, 0); |
| 1064 |
|
| 1065 |
return $pairs; |
| 1066 |
} |
| 1067 |
|
| 1068 |
/** |
| 1069 |
* The first matching string anywhere under a node. |
| 1070 |
* |
| 1071 |
* @since 2.14.0 |
| 1072 |
* @param array<mixed> $node Tree node. |
| 1073 |
* @param string[] $fragments Key fragments to match. |
| 1074 |
* @param bool $skip_children Whether to stay out of `children`, |
| 1075 |
* which holds other elements rather than |
| 1076 |
* this one's own fields. |
| 1077 |
* @return string |
| 1078 |
*/ |
| 1079 |
private static function deep_fragment_value(array $node, array $fragments, bool $skip_children): string { |
| 1080 |
$found = ''; |
| 1081 |
|
| 1082 |
$walk = static function ($current, int $depth) use (&$walk, &$found, $fragments, $skip_children): void { |
| 1083 |
if ('' !== $found || $depth > self::MAX_TREE_DEPTH) { |
| 1084 |
return; |
| 1085 |
} |
| 1086 |
|
| 1087 |
$current = self::as_tree_node($current); |
| 1088 |
if (null === $current) { |
| 1089 |
return; |
| 1090 |
} |
| 1091 |
|
| 1092 |
$value = self::first_fragment_value($current, $fragments); |
| 1093 |
if ('' !== $value) { |
| 1094 |
$found = $value; |
| 1095 |
|
| 1096 |
return; |
| 1097 |
} |
| 1098 |
|
| 1099 |
foreach ($current as $key => $child) { |
| 1100 |
if ($skip_children && is_string($key) && 'children' === strtolower($key)) { |
| 1101 |
continue; |
| 1102 |
} |
| 1103 |
|
| 1104 |
$walk($child, $depth + 1); |
| 1105 |
} |
| 1106 |
}; |
| 1107 |
|
| 1108 |
$walk($node, 0); |
| 1109 |
|
| 1110 |
return $found; |
| 1111 |
} |
| 1112 |
|
| 1113 |
/** |
| 1114 |
* The first string on a node whose key matches one of the fragments. |
| 1115 |
* |
| 1116 |
* An exact key wins over a fragment of one, so an `AccordionContent` |
| 1117 |
* carrying `title` beside `title_tag` yields the question rather than the |
| 1118 |
* heading level it is rendered at. Among fragment matches the node's own key |
| 1119 |
* order decides, so a builder that writes `question` and `title` yields |
| 1120 |
* whichever it wrote first rather than whichever this class prefers. |
| 1121 |
* |
| 1122 |
* @since 2.14.0 |
| 1123 |
* @param array<mixed> $node Tree node. |
| 1124 |
* @param string[] $fragments Key fragments to match. |
| 1125 |
* @return string |
| 1126 |
*/ |
| 1127 |
private static function first_fragment_value(array $node, array $fragments): string { |
| 1128 |
$fallback = ''; |
| 1129 |
|
| 1130 |
foreach ($node as $key => $value) { |
| 1131 |
if (!is_string($key) || !is_string($value) || '' === trim($value)) { |
| 1132 |
continue; |
| 1133 |
} |
| 1134 |
|
| 1135 |
$lower = strtolower($key); |
| 1136 |
|
| 1137 |
if (in_array($lower, $fragments, true)) { |
| 1138 |
return trim($value); |
| 1139 |
} |
| 1140 |
|
| 1141 |
if ('' !== $fallback) { |
| 1142 |
continue; |
| 1143 |
} |
| 1144 |
|
| 1145 |
foreach ($fragments as $fragment) { |
| 1146 |
if (false !== strpos($lower, $fragment)) { |
| 1147 |
$fallback = trim($value); |
| 1148 |
|
| 1149 |
break; |
| 1150 |
} |
| 1151 |
} |
| 1152 |
} |
| 1153 |
|
| 1154 |
return $fallback; |
| 1155 |
} |
| 1156 |
|
| 1157 |
/** |
| 1158 |
* Collect accordion pairs from an Oxygen classic shortcode tree. |
| 1159 |
* |
| 1160 |
* Parsed rather than rendered. `do_shortcode()` depends on Oxygen having |
| 1161 |
* registered its `ct_*` handlers in the current request, which it has not |
| 1162 |
* during bulk analysis, the post-list column, cron, REST or MCP — the same |
| 1163 |
* trap that once had raw shortcode source counted as a page's prose (#776). |
| 1164 |
* |
| 1165 |
* `ct_options` is deliberately not read, which means a composite element's |
| 1166 |
* accordion contributes nothing from this form. Oxygen base64-encodes a |
| 1167 |
* composite element's field values inside that blob, so walking it would |
| 1168 |
* match the encoded string as a question and publish base64 as an FAQ. |
| 1169 |
* `Builder_Content` reached the same conclusion for word counting: the JSON |
| 1170 |
* tree is the only readable source for a composite element, and an Oxygen |
| 1171 |
* classic 4.x site stores one beside its shortcodes. Reporting nothing is |
| 1172 |
* the right answer for a 3.x site that stores only shortcodes. |
| 1173 |
* |
| 1174 |
* @since 2.14.0 |
| 1175 |
* @param string $stored Stored shortcode string. |
| 1176 |
* @return array<int, array{question: string, answer: string}> |
| 1177 |
*/ |
| 1178 |
private static function accordion_pairs_from_shortcodes(string $stored): array { |
| 1179 |
if (false === strpos($stored, '[')) { |
| 1180 |
return []; |
| 1181 |
} |
| 1182 |
|
| 1183 |
$fragments = implode('|', array_map('preg_quote', self::ACCORDION_NAME_FRAGMENTS)); |
| 1184 |
$pattern = '/\[([a-z0-9_]*(?:' . $fragments . ')[a-z0-9_]*)\b([^\]]*)\](.*?)\[\/\1\]/is'; |
| 1185 |
|
| 1186 |
if (!preg_match_all($pattern, $stored, $regions, PREG_SET_ORDER)) { |
| 1187 |
return []; |
| 1188 |
} |
| 1189 |
|
| 1190 |
$pairs = []; |
| 1191 |
|
| 1192 |
foreach ($regions as $region) { |
| 1193 |
// An item tag is itself an accordion-named tag on most releases |
| 1194 |
// (`oxy_pro_accordion_item`), so the outermost match is the whole |
| 1195 |
// accordion and its items are matched again inside it. |
| 1196 |
$inner = (string) ($region[3] ?? ''); |
| 1197 |
|
| 1198 |
$pairs = array_merge($pairs, self::shortcode_items($inner)); |
| 1199 |
} |
| 1200 |
|
| 1201 |
return $pairs; |
| 1202 |
} |
| 1203 |
|
| 1204 |
/** |
| 1205 |
* Question/answer pairs from the item tags inside an accordion. |
| 1206 |
* |
| 1207 |
* @since 2.14.0 |
| 1208 |
* @param string $inner Shortcode string inside the accordion tag. |
| 1209 |
* @return array<int, array{question: string, answer: string}> |
| 1210 |
*/ |
| 1211 |
private static function shortcode_items(string $inner): array { |
| 1212 |
if (!preg_match_all('/\[([a-z0-9_]+)\b([^\]]*)\](.*?)\[\/\1\]/is', $inner, $items, PREG_SET_ORDER)) { |
| 1213 |
return []; |
| 1214 |
} |
| 1215 |
|
| 1216 |
$pairs = []; |
| 1217 |
|
| 1218 |
foreach ($items as $item) { |
| 1219 |
$attributes = self::shortcode_attributes((string) ($item[2] ?? '')); |
| 1220 |
$question = self::first_fragment_value($attributes, self::QUESTION_KEY_FRAGMENTS); |
| 1221 |
$answer = trim(wp_strip_all_tags(self::strip_shortcode_tags((string) ($item[3] ?? '')))); |
| 1222 |
|
| 1223 |
if ('' !== $question && '' !== $answer) { |
| 1224 |
$pairs[] = [ |
| 1225 |
'question' => $question, |
| 1226 |
'answer' => $answer, |
| 1227 |
]; |
| 1228 |
|
| 1229 |
continue; |
| 1230 |
} |
| 1231 |
} |
| 1232 |
|
| 1233 |
return $pairs; |
| 1234 |
} |
| 1235 |
|
| 1236 |
/** |
| 1237 |
* Parse a shortcode tag's attributes into a name => value map. |
| 1238 |
* |
| 1239 |
* Parsed into a map rather than probed with one regex per attribute name, so |
| 1240 |
* the same fragment matching the tree walker uses applies here too. Probing |
| 1241 |
* by name cannot do that: `\b` does not match inside `accordion_title`, |
| 1242 |
* because the underscore before it is a word character, so a pattern built |
| 1243 |
* for `title` silently found nothing on the one attribute Oxygen writes. |
| 1244 |
* |
| 1245 |
* `shortcode_parse_atts()` is not used. It arrives with the shortcode API |
| 1246 |
* rather than being always available, and it folds positional attributes |
| 1247 |
* into numeric keys that would then be matched as content. |
| 1248 |
* |
| 1249 |
* @since 2.14.0 |
| 1250 |
* @param string $attributes Raw attribute string from a shortcode tag. |
| 1251 |
* @return array<string, string> |
| 1252 |
*/ |
| 1253 |
private static function shortcode_attributes(string $attributes): array { |
| 1254 |
$pattern = '/([a-z0-9_:-]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i'; |
| 1255 |
|
| 1256 |
if (!preg_match_all($pattern, $attributes, $matches, PREG_SET_ORDER)) { |
| 1257 |
return []; |
| 1258 |
} |
| 1259 |
|
| 1260 |
$parsed = []; |
| 1261 |
|
| 1262 |
foreach ($matches as $match) { |
| 1263 |
$name = strtolower((string) $match[1]); |
| 1264 |
|
| 1265 |
// First wins, so a repeated attribute cannot have its value |
| 1266 |
// replaced by a later empty one. |
| 1267 |
if (isset($parsed[$name])) { |
| 1268 |
continue; |
| 1269 |
} |
| 1270 |
|
| 1271 |
$value = '' !== ($match[2] ?? '') ? $match[2] : ($match[3] ?? ''); |
| 1272 |
|
| 1273 |
$parsed[$name] = trim(wp_specialchars_decode((string) $value, ENT_QUOTES)); |
| 1274 |
} |
| 1275 |
|
| 1276 |
return $parsed; |
| 1277 |
} |
| 1278 |
|
| 1279 |
/** |
| 1280 |
* Remove shortcode tags while keeping the text between them. |
| 1281 |
* |
| 1282 |
* `strip_shortcodes()` is no help: it only knows shortcodes registered in |
| 1283 |
* the current request, and Oxygen registers none outside a front-end view. |
| 1284 |
* |
| 1285 |
* @since 2.14.0 |
| 1286 |
* @param string $content Shortcode string. |
| 1287 |
* @return string |
| 1288 |
*/ |
| 1289 |
private static function strip_shortcode_tags(string $content): string { |
| 1290 |
return (string) preg_replace('/\[\/?[a-z0-9_]+\b[^\]]*\]/i', ' ', $content); |
| 1291 |
} |
| 1292 |
|
| 1293 |
/** |
| 1294 |
* FAQ widgets in a post's Elementor tree. |
| 1295 |
* |
| 1296 |
* @param \WP_Post $post Post to read. |
| 1297 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 1298 |
*/ |
| 1299 |
private static function elementor_groups(\WP_Post $post): array { |
| 1300 |
$raw = get_post_meta($post->ID, '_elementor_data', true); |
| 1301 |
if (empty($raw) || !is_string($raw)) { |
| 1302 |
return []; |
| 1303 |
} |
| 1304 |
|
| 1305 |
$elements = json_decode($raw, true); |
| 1306 |
if (!is_array($elements)) { |
| 1307 |
return []; |
| 1308 |
} |
| 1309 |
|
| 1310 |
return self::walk_elementor($elements); |
| 1311 |
} |
| 1312 |
|
| 1313 |
/** |
| 1314 |
* Recurse an Elementor element tree. |
| 1315 |
* |
| 1316 |
* @param array $elements Elementor elements. |
| 1317 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 1318 |
*/ |
| 1319 |
private static function walk_elementor(array $elements): array { |
| 1320 |
$groups = []; |
| 1321 |
|
| 1322 |
foreach ($elements as $element) { |
| 1323 |
if (!is_array($element)) { |
| 1324 |
continue; |
| 1325 |
} |
| 1326 |
|
| 1327 |
if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) { |
| 1328 |
$settings = is_array($element['settings'] ?? null) ? $element['settings'] : []; |
| 1329 |
|
| 1330 |
$groups[] = [ |
| 1331 |
'source' => self::SOURCE_ELEMENTOR, |
| 1332 |
// Mirrors FAQ_Widget: schema unless the toggle is off. |
| 1333 |
'schema' => 'yes' === ($settings['output_schema'] ?? 'yes'), |
| 1334 |
'pairs' => self::rows($settings['faqs'] ?? []), |
| 1335 |
]; |
| 1336 |
} |
| 1337 |
|
| 1338 |
if (!empty($element['elements']) && is_array($element['elements'])) { |
| 1339 |
$groups = array_merge($groups, self::walk_elementor($element['elements'])); |
| 1340 |
} |
| 1341 |
} |
| 1342 |
|
| 1343 |
return $groups; |
| 1344 |
} |
| 1345 |
|
| 1346 |
/** |
| 1347 |
* FAQ elements in a post's Bricks tree. |
| 1348 |
* |
| 1349 |
* Reads the tree Bricks will actually render — resolved through |
| 1350 |
* `Builder_Content`, so a page whose content lives on a content template or |
| 1351 |
* inside a component is covered, and one switched back to the block editor |
| 1352 |
* is not. |
| 1353 |
* |
| 1354 |
* Unlike the block, this is not gated on Bricks owning `post_content`: a |
| 1355 |
* Bricks element is on the page whenever Bricks renders the page, which is |
| 1356 |
* exactly what resolving the tree already establishes (#626). |
| 1357 |
* |
| 1358 |
* The element's own settings are read here rather than through |
| 1359 |
* `FAQ_Element`, whose class extends `Bricks\Element` and so cannot even be |
| 1360 |
* loaded when the theme is inactive — which is exactly the case that still |
| 1361 |
* has a stored tree, on a site that has since switched themes. |
| 1362 |
* |
| 1363 |
* The tree is flat, so no recursion: `Builder_Content::bricks_tree()` |
| 1364 |
* splices component definitions into the same list. |
| 1365 |
* |
| 1366 |
* @param \WP_Post $post Post to read. |
| 1367 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 1368 |
*/ |
| 1369 |
private static function bricks_groups(\WP_Post $post): array { |
| 1370 |
$groups = []; |
| 1371 |
|
| 1372 |
foreach (Builder_Content::bricks_tree((int) $post->ID) as $element) { |
| 1373 |
if (!is_array($element) || ($element['name'] ?? '') !== self::FAQ_BRICKS_ELEMENT) { |
| 1374 |
continue; |
| 1375 |
} |
| 1376 |
|
| 1377 |
$settings = is_array($element['settings'] ?? null) ? $element['settings'] : []; |
| 1378 |
|
| 1379 |
$groups[] = [ |
| 1380 |
'source' => self::SOURCE_BRICKS, |
| 1381 |
// Mirrors FAQ_Element: a cleared Bricks checkbox loses its key. |
| 1382 |
'schema' => !empty($settings['outputSchema']), |
| 1383 |
'pairs' => self::rows($settings['faqs'] ?? []), |
| 1384 |
]; |
| 1385 |
} |
| 1386 |
|
| 1387 |
return $groups; |
| 1388 |
} |
| 1389 |
|
| 1390 |
/** |
| 1391 |
* FAQ modules in a post's Beaver Builder layout. |
| 1392 |
* |
| 1393 |
* Beaver Builder keeps its layout in postmeta as a map of node objects and |
| 1394 |
* leaves `post_content` alone, so — unlike Bricks — there is no |
| 1395 |
* "supersedes post_content" gate to apply: a block FAQ left in the body and |
| 1396 |
* a module FAQ in the layout can both genuinely be on the page, and both |
| 1397 |
* belong in the one FAQPage. |
| 1398 |
* |
| 1399 |
* The published layout is preferred over the draft for the same reason the |
| 1400 |
* rest of the plugin prefers it: a draft holds edits no visitor has been |
| 1401 |
* served yet, and schema must describe the page as delivered. |
| 1402 |
* |
| 1403 |
* @param \WP_Post $post Post to read. |
| 1404 |
* @return array<int, array{source: string, schema: bool, pairs: array}> |
| 1405 |
*/ |
| 1406 |
private static function beaver_groups(\WP_Post $post): array { |
| 1407 |
$layout = get_post_meta($post->ID, '_fl_builder_data', true); |
| 1408 |
|
| 1409 |
if (!is_array($layout) || empty($layout)) { |
| 1410 |
return []; |
| 1411 |
} |
| 1412 |
|
| 1413 |
$groups = []; |
| 1414 |
|
| 1415 |
foreach ($layout as $node) { |
| 1416 |
$settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null); |
| 1417 |
$settings = is_object($settings) ? get_object_vars($settings) : $settings; |
| 1418 |
|
| 1419 |
if (!is_array($settings) || ($settings['type'] ?? '') !== self::FAQ_BEAVER_MODULE) { |
| 1420 |
continue; |
| 1421 |
} |
| 1422 |
|
| 1423 |
$groups[] = [ |
| 1424 |
'source' => self::SOURCE_BEAVER, |
| 1425 |
// Mirrors ThinkRank_Beaver_FAQ_Module::schema_enabled(): Beaver |
| 1426 |
// Builder stores a cleared toggle as the string '0'. |
| 1427 |
'schema' => !empty($settings['output_schema']), |
| 1428 |
'pairs' => self::rows($settings['faqs'] ?? []), |
| 1429 |
]; |
| 1430 |
} |
| 1431 |
|
| 1432 |
return $groups; |
| 1433 |
} |
| 1434 |
|
| 1435 |
/** |
| 1436 |
* Normalise a repeater to a list of arrays. |
| 1437 |
* |
| 1438 |
* Beaver Builder stores its rows as stdClass, everything else as arrays. |
| 1439 |
* |
| 1440 |
* @param mixed $rows Stored repeater. |
| 1441 |
* @return array<int, array<string, mixed>> |
| 1442 |
*/ |
| 1443 |
private static function rows($rows): array { |
| 1444 |
if (!is_array($rows)) { |
| 1445 |
return []; |
| 1446 |
} |
| 1447 |
|
| 1448 |
$normalised = []; |
| 1449 |
|
| 1450 |
foreach ($rows as $row) { |
| 1451 |
if (is_object($row)) { |
| 1452 |
$row = get_object_vars($row); |
| 1453 |
} |
| 1454 |
|
| 1455 |
if (is_array($row)) { |
| 1456 |
$normalised[] = $row; |
| 1457 |
} |
| 1458 |
} |
| 1459 |
|
| 1460 |
return $normalised; |
| 1461 |
} |
| 1462 |
} |
| 1463 |
|