PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
thinkrank / includes / abilities / content / class-update-faq.php

class-update-faq.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.11.0, at includes/abilities/content/class-update-faq.php

455 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Update FAQ ability.
4 *
5 * @package ThinkRank\Abilities\Content
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Abilities\Content;
11
12 use ThinkRank\Admin\Importers\Block_Converter;
13 use ThinkRank\Frontend\Schema_Graph;
14 use ThinkRank\SEO\FAQ_Content;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly.
18 }
19
20 /**
21 * Writes FAQ questions into a post as a visible `thinkrank/faq` block.
22 *
23 * The whole design follows from one rule: Google's structured data policy says
24 * "don't mark up content that is not visible to readers of the page", and a
25 * FAQPage describing questions nobody can read is a manual action, not a
26 * missed opportunity. So this writes the block a human would have inserted —
27 * a real `<details>` accordion in `post_content` — and lets the schema graph
28 * pick it up the way it already picks up a hand-built one. There is no
29 * schema-only path, deliberately.
30 *
31 * That rule is also why a builder-rendered post is refused rather than
32 * written. Elementor, Bricks and Beaver replace or discard `post_content`, so a
33 * block stored there would be invisible on the page and the write would produce
34 * exactly the violation above. Those surfaces have their own FAQ modules, which
35 * `get-faq` reads and a human edits in the builder.
36 *
37 * Two properties of the write matter:
38 *
39 * - **Byte-level editing.** Blocks are located with core's tokenizer and
40 * replaced by byte range, so every other block in the post is left exactly as
41 * its author saved it. A parse_blocks()/serialize_blocks() round trip would
42 * quietly normalise markup across the whole post.
43 * - **Markup parity.** The block is serialized through Block_Converter, which
44 * is a maintained mirror of `src/blocks/faq-block/save.js` with a test
45 * pinning it. Gutenberg validates a block by re-running save() and comparing
46 * byte for byte, so markup that is merely equivalent opens as "this block
47 * contains unexpected or invalid content".
48 */
49 class Update_FAQ extends FAQ_Ability_Base {
50
51 /**
52 * Most questions one call may write.
53 *
54 * An FAQ block is a reading experience, not a dump; past a few dozen
55 * questions the page is a worse answer than a structured article, and the
56 * cap keeps one call from replacing a post's body with a wall of accordions.
57 */
58 private const MAX_ITEMS = 50;
59
60 /**
61 * Constructor.
62 */
63 public function __construct() {
64 $this->id = 'thinkrank/update-faq';
65 $this->label = __( 'Update ThinkRank FAQ', 'thinkrank' );
66 $this->description = __( 'Add FAQ questions and answers to a post as a visible ThinkRank FAQ block, which also produces FAQPage schema. Use mode "append" to add a block alongside anything already there, or "replace" to remove every ThinkRank FAQ block on the post first. Only posts built with the block editor can be written: a post rendered by Elementor, Bricks or Beaver is refused with unsupported_builder, because a block stored in its content would never be shown and marking up invisible content breaks Google\'s structured data policy. Call get-faq first to see which builder a post uses and what it already asks. Note that Google shows FAQ rich results only for well-known, authoritative government and health websites, so for most sites the value is being quotable by answer engines rather than a rich result.', 'thinkrank' );
67 }
68
69 /**
70 * {@inheritDoc}
71 *
72 * @return array<string, bool|float|string>
73 */
74 public function get_annotations() {
75 return [
76 'readonly' => false,
77 // Not destructive by the house rule (#675): the write goes through
78 // wp_update_post(), so core stores a revision of the content as it
79 // was, and this ability returns its id. "replace" does delete FAQ
80 // blocks, but nothing is lost that one revision restore does not
81 // bring back — and an approval prompt on every FAQ edit is what
82 // made the connector unusable the last time this word was used for
83 // "writes something".
84 'destructive' => false,
85 'idempotent' => false,
86 'priority' => 0.6,
87 'openWorldHint' => false,
88 ];
89 }
90
91 /**
92 * {@inheritDoc}
93 *
94 * @return array<string, mixed>
95 */
96 public function get_input_schema() {
97 return [
98 'type' => 'object',
99 'additionalProperties' => false,
100 'required' => [ 'post_id', 'items' ],
101 'properties' => [
102 'post_id' => [
103 'type' => 'integer',
104 'description' => __( 'Post ID from list-content-items.', 'thinkrank' ),
105 ],
106 'mode' => [
107 'type' => 'string',
108 'enum' => [ 'append', 'replace' ],
109 'default' => 'append',
110 'description' => __( 'append adds a new FAQ block and leaves existing ones alone. replace removes every ThinkRank FAQ block on the post first, discarding any styling those blocks carried.', 'thinkrank' ),
111 ],
112 'items' => [
113 'type' => 'array',
114 'minItems' => 1,
115 'maxItems' => self::MAX_ITEMS,
116 'description' => __( 'The questions to write, in the order they should appear.', 'thinkrank' ),
117 'items' => [
118 'type' => 'object',
119 'additionalProperties' => false,
120 'required' => [ 'question', 'answer' ],
121 'properties' => [
122 'question' => [
123 'type' => 'string',
124 'description' => __( 'The question, as a reader would ask it. Plain text; any markup is stripped.', 'thinkrank' ),
125 ],
126 'answer' => [
127 'type' => 'string',
128 'description' => __( 'The answer. Inline HTML is allowed and rendered; anything KSES rejects is removed.', 'thinkrank' ),
129 ],
130 'image_id' => [
131 'type' => 'integer',
132 'description' => __( 'Optional attachment ID to show with this answer. Use list-images to find one.', 'thinkrank' ),
133 ],
134 ],
135 ],
136 ],
137 ],
138 ];
139 }
140
141 /**
142 * {@inheritDoc}
143 *
144 * @return array<string, mixed>
145 */
146 public function get_output_schema() {
147 return [
148 'type' => 'object',
149 'properties' => [
150 'success' => [ 'type' => 'boolean' ],
151 'post_id' => [ 'type' => 'integer' ],
152 'mode' => [ 'type' => 'string' ],
153 'blocks_removed' => [
154 'type' => 'integer',
155 'description' => __( 'ThinkRank FAQ blocks deleted from the post. Always 0 in append mode.', 'thinkrank' ),
156 ],
157 'revision_id' => [
158 'type' => 'integer',
159 'description' => __( 'The revision holding the content as it was before this write, or 0 when the post type stores no revisions.', 'thinkrank' ),
160 ],
161 'faqpage_emitted' => [ 'type' => 'boolean' ],
162 'total' => [
163 'type' => 'integer',
164 'description' => __( 'Questions on the post after the write, across every surface.', 'thinkrank' ),
165 ],
166 'items' => [
167 'type' => 'array',
168 'items' => [
169 'type' => 'object',
170 'properties' => $this->item_properties(),
171 ],
172 ],
173 ],
174 ];
175 }
176
177 /**
178 * Execute ability.
179 *
180 * @param array<string, mixed> $input Ability input payload.
181 * @return array<string, mixed>|\WP_Error
182 */
183 public function execute( $input ) {
184 $input = (array) $input;
185 $post = $this->resolve_post( (int) ( $input['post_id'] ?? 0 ) );
186
187 if ( $post instanceof \WP_Error ) {
188 return $post;
189 }
190
191 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
192 return new \WP_Error(
193 'thinkrank_cannot_edit_post',
194 __( 'You are not allowed to edit this post.', 'thinkrank' ),
195 [ 'status' => 403 ]
196 );
197 }
198
199 $builder = FAQ_Content::builder( (int) $post->ID );
200 if ( '' !== $builder ) {
201 return new \WP_Error(
202 'thinkrank_unsupported_builder',
203 sprintf(
204 /* translators: %s: page builder name. */
205 __( 'This post is rendered by %s, which replaces the post content, so a FAQ block written here would never be shown. Add the questions with that builder\'s own ThinkRank FAQ module instead. Marking up content a reader cannot see breaks Google\'s structured data policy.', 'thinkrank' ),
206 $this->builder_label( $builder )
207 ),
208 [
209 'status' => 422,
210 'builder' => $builder,
211 ]
212 );
213 }
214
215 $rows = $this->rows_from_input( $input['items'] ?? [] );
216
217 if ( [] === $rows ) {
218 return new \WP_Error(
219 'thinkrank_no_faq_items',
220 __( 'Every item was missing a question or an answer. An FAQ entry needs both.', 'thinkrank' ),
221 [ 'status' => 400 ]
222 );
223 }
224
225 $block = Block_Converter::serialize_faq_block( $rows );
226
227 if ( '' === $block ) {
228 return new \WP_Error(
229 'thinkrank_no_faq_items',
230 __( 'The items produced no renderable FAQ block.', 'thinkrank' ),
231 [ 'status' => 400 ]
232 );
233 }
234
235 $mode = 'replace' === ( $input['mode'] ?? 'append' ) ? 'replace' : 'append';
236 $content = (string) $post->post_content;
237 $removed = 0;
238
239 if ( 'replace' === $mode ) {
240 $stripped = $this->strip_faq_blocks( $content );
241
242 if ( is_string( $stripped ) ) {
243 return new \WP_Error(
244 'thinkrank_unparsable_content',
245 sprintf(
246 /* translators: %s: reason the content could not be edited. */
247 __( 'This post\'s content could not be edited safely: %s Fix the block markup in the editor and try again.', 'thinkrank' ),
248 $stripped
249 ),
250 [ 'status' => 422 ]
251 );
252 }
253
254 $content = $stripped['content'];
255 $removed = $stripped['removed'];
256 }
257
258 $content = '' === trim( $content ) ? $block : rtrim( $content ) . "\n\n" . $block;
259
260 $revision_id = $this->latest_revision_id( (int) $post->ID );
261
262 $updated = wp_update_post(
263 [
264 'ID' => (int) $post->ID,
265 // Slashed, because wp_update_post() unslashes what it is given.
266 // The block's attribute JSON escapes `<` as `\u003c`, so an
267 // unslashed write stored `u003c` and the block's own attributes
268 // no longer matched its markup: any HTML in an answer was lost
269 // on the next save, and the block opened as invalid content.
270 'post_content' => wp_slash( $content ),
271 ],
272 true
273 );
274
275 if ( is_wp_error( $updated ) ) {
276 return $updated;
277 }
278
279 $this->purge_caches( (int) $post->ID );
280
281 $fresh = get_post( (int) $post->ID );
282 $items = $fresh instanceof \WP_Post ? FAQ_Content::items( $fresh ) : [];
283
284 return [
285 'success' => true,
286 'post_id' => (int) $post->ID,
287 'mode' => $mode,
288 'blocks_removed' => $removed,
289 'revision_id' => $this->revision_since( (int) $post->ID, $revision_id ),
290 'faqpage_emitted' => $fresh instanceof \WP_Post ? Schema_Graph::will_emit_faqpage( $fresh ) : false,
291 'total' => count( $items ),
292 'items' => $items,
293 ];
294 }
295
296 /**
297 * Turn the input items into block repeater rows.
298 *
299 * Rows carry every key the block registration declares, because that is what
300 * the editor stores and what it will write back on the next save.
301 *
302 * @param mixed $items Input items.
303 * @return array<int, array<string, mixed>>
304 */
305 private function rows_from_input( $items ): array {
306 if ( ! is_array( $items ) ) {
307 return [];
308 }
309
310 $rows = [];
311
312 foreach ( array_slice( $items, 0, self::MAX_ITEMS ) as $item ) {
313 if ( ! is_array( $item ) ) {
314 continue;
315 }
316
317 // The question renders inside <summary> and is read as plain text by
318 // the schema builder, so markup in it is noise at best.
319 $question = trim( wp_strip_all_tags( (string) ( $item['question'] ?? '' ) ) );
320 $answer = trim( wp_kses_post( (string) ( $item['answer'] ?? '' ) ) );
321
322 if ( '' === $question || '' === $answer ) {
323 continue;
324 }
325
326 $image_id = (int) ( $item['image_id'] ?? 0 );
327 $image_url = '';
328 $image_alt = '';
329
330 // A deleted or non-image attachment resolves to nothing rather than
331 // writing a broken <img> into the post.
332 if ( $image_id > 0 && wp_attachment_is_image( $image_id ) ) {
333 $image_url = (string) wp_get_attachment_url( $image_id );
334 $image_alt = (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true );
335 }
336
337 $rows[] = [
338 'question' => $question,
339 'answer' => $answer,
340 'imageId' => '' === $image_url ? 0 : $image_id,
341 'imageUrl' => $image_url,
342 'imageAlt' => $image_alt,
343 ];
344 }
345
346 return $rows;
347 }
348
349 /**
350 * Remove every `thinkrank/faq` block from content, by byte range.
351 *
352 * @param string $content Post content.
353 * @return array{content: string, removed: int}|string The new content, or why it could not be edited.
354 */
355 private function strip_faq_blocks( string $content ) {
356 $spans = Block_Converter::find_faq_blocks( $content );
357
358 if ( is_string( $spans ) ) {
359 return $spans;
360 }
361
362 if ( [] === $spans ) {
363 return [
364 'content' => $content,
365 'removed' => 0,
366 ];
367 }
368
369 $out = '';
370 $cursor = 0;
371
372 foreach ( $spans as $span ) {
373 $out .= substr( $content, $cursor, $span['start'] - $cursor );
374 $cursor = $span['end'];
375 }
376
377 return [
378 'content' => $out . substr( $content, $cursor ),
379 'removed' => count( $spans ),
380 ];
381 }
382
383 /**
384 * A readable name for a builder, for the refusal message.
385 *
386 * @param string $builder Builder key.
387 * @return string
388 */
389 private function builder_label( string $builder ): string {
390 $labels = [
391 FAQ_Content::SOURCE_ELEMENTOR => __( 'Elementor', 'thinkrank' ),
392 FAQ_Content::SOURCE_BRICKS => __( 'Bricks', 'thinkrank' ),
393 FAQ_Content::SOURCE_BEAVER => __( 'Beaver Builder', 'thinkrank' ),
394 ];
395
396 return $labels[ $builder ] ?? $builder;
397 }
398
399 /**
400 * The newest revision ID for a post, or 0 when it keeps none.
401 *
402 * @param int $post_id Post ID.
403 * @return int
404 */
405 private function latest_revision_id( int $post_id ): int {
406 $revisions = wp_get_post_revisions( $post_id, [ 'numberposts' => 1 ] );
407
408 if ( ! is_array( $revisions ) || [] === $revisions ) {
409 return 0;
410 }
411
412 $first = reset( $revisions );
413
414 return $first instanceof \WP_Post ? (int) $first->ID : 0;
415 }
416
417 /**
418 * The revision core stored for this write, if it stored one.
419 *
420 * Reported rather than assumed: a post type without revision support, or a
421 * site that has filtered them off, silently keeps none, and telling the
422 * caller a revision exists when it does not is how "just roll it back"
423 * becomes bad advice.
424 *
425 * @param int $post_id Post ID.
426 * @param int $previous Newest revision ID before the write.
427 * @return int The new revision ID, or 0 when none was created.
428 */
429 private function revision_since( int $post_id, int $previous ): int {
430 $latest = $this->latest_revision_id( $post_id );
431
432 return $latest === $previous ? 0 : $latest;
433 }
434
435 /**
436 * Drop cached renderings of the post that just changed.
437 *
438 * Guarded because Cache_Purger lands with #763; without it the write still
439 * happens and only the page cache lags, which is the behaviour every other
440 * content write on this site already has.
441 *
442 * @param int $post_id Post ID.
443 * @return void
444 */
445 private function purge_caches( int $post_id ): void {
446 if ( class_exists( 'ThinkRank\\SEO\\Cache_Purger' ) ) {
447 \ThinkRank\SEO\Cache_Purger::purge_posts( [ $post_id ] );
448
449 return;
450 }
451
452 clean_post_cache( $post_id );
453 }
454 }
455