PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.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 All 55 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.14.0, at includes/abilities/content/class-update-faq.php

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