PluginProbe
Gutenberg / 23.0.1
Gutenberg v23.0.1
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / guidelines / class-gutenberg-guidelines-rest-controller.php

class-gutenberg-guidelines-rest-controller.php in Gutenberg 23.0.1, at lib/experimental/guidelines/class-gutenberg-guidelines-rest-controller.php

700 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Guidelines REST API Controller.
4 *
5 * Extends WP_REST_Posts_Controller to inherit standard WordPress CRUD behavior,
6 * permission checks, and response formatting. Follows the pattern used by
7 * WP_REST_Global_Styles_Controller.
8 *
9 * @package gutenberg
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * REST API controller for Guidelines.
18 */
19 class Gutenberg_Guidelines_REST_Controller extends WP_REST_Posts_Controller {
20
21 /**
22 * Maximum length for guideline text strings.
23 *
24 * @var int
25 */
26 const MAX_GUIDELINE_LENGTH = 5000;
27
28 /**
29 * Maximum length for category label strings.
30 *
31 * @var int
32 */
33 const MAX_LABEL_LENGTH = 200;
34
35 /**
36 * Constructor.
37 */
38 public function __construct() {
39 parent::__construct( Gutenberg_Guidelines_Post_Type::POST_TYPE );
40 }
41
42 /**
43 * Registers the routes for guidelines.
44 *
45 * Calls parent to register standard /{id} CRUD routes, then overrides the
46 * collection route with a singleton GET endpoint.
47 */
48 public function register_routes() {
49 parent::register_routes();
50
51 // Override collection route with singleton GET + create.
52 register_rest_route(
53 $this->namespace,
54 '/' . $this->rest_base,
55 array(
56 array(
57 'methods' => WP_REST_Server::READABLE,
58 'callback' => array( $this, 'get_guidelines' ),
59 'permission_callback' => array( $this, 'get_guidelines_permissions_check' ),
60 'args' => array(
61 'category' => array(
62 'description' => __( 'Limit response to a specific guideline category.', 'gutenberg' ),
63 'type' => 'string',
64 'enum' => Gutenberg_Guidelines_Post_Type::VALID_CATEGORIES,
65 'sanitize_callback' => 'sanitize_text_field',
66 ),
67 'block' => array(
68 'description' => __( 'Limit response to guidelines for a specific block type.', 'gutenberg' ),
69 'type' => 'string',
70 'sanitize_callback' => 'sanitize_text_field',
71 ),
72 'status' => array(
73 'description' => __( 'Limit response to guidelines with a specific status.', 'gutenberg' ),
74 'type' => 'string',
75 'enum' => Gutenberg_Guidelines_Post_Type::VALID_STATUSES,
76 'sanitize_callback' => 'sanitize_text_field',
77 ),
78 ),
79 ),
80 array(
81 'methods' => WP_REST_Server::CREATABLE,
82 'callback' => array( $this, 'create_item' ),
83 'permission_callback' => array( $this, 'create_item_permissions_check' ),
84 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
85 ),
86 'schema' => array( $this, 'get_public_item_schema' ),
87 ),
88 true
89 );
90 }
91
92 /**
93 * Retrieves the query params for the collection.
94 *
95 * Overridden to return empty since we use a singleton pattern, not a collection.
96 *
97 * @return array Empty collection parameters.
98 */
99 public function get_collection_params() {
100 return array();
101 }
102
103 /**
104 * Checks if a given request has access to read the singleton guidelines.
105 *
106 * @param WP_REST_Request $request Full details about the request.
107 * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
108 */
109 public function get_guidelines_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
110 $post_type = get_post_type_object( $this->post_type );
111 if ( ! current_user_can( $post_type->cap->read ) ) {
112 return new WP_Error(
113 'rest_forbidden',
114 __( 'Sorry, you are not allowed to view the guidelines.', 'gutenberg' ),
115 array( 'status' => rest_authorization_required_code() )
116 );
117 }
118
119 return true;
120 }
121
122 /**
123 * Gets the singleton guidelines.
124 *
125 * Supports query parameters:
126 * - ?status=publish|draft - Filter by status
127 * - ?category=copy|images|site|blocks|additional - Return only specific category
128 * - ?block=core/paragraph - Return only specific block's guidelines
129 *
130 * @param WP_REST_Request $request Full details about the request.
131 * @return WP_REST_Response Response object.
132 */
133 public function get_guidelines( $request ) {
134 $status_filter = $request->get_param( 'status' );
135 $post = $this->get_guidelines_post( $status_filter );
136
137 if ( ! $post ) {
138 $empty_status = $status_filter ? $status_filter : 'draft';
139 return rest_ensure_response(
140 array(
141 'id' => 0,
142 'status' => $empty_status,
143 'guideline_categories' => new stdClass(),
144 )
145 );
146 }
147
148 return $this->prepare_item_for_response( $post, $request );
149 }
150
151 /**
152 * Creates guidelines.
153 *
154 * Enforces singleton pattern — only one guidelines post per site.
155 *
156 * @param WP_REST_Request $request Full details about the request.
157 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure.
158 */
159 public function create_item( $request ) {
160 $existing = $this->get_guidelines_post();
161 if ( $existing ) {
162 return new WP_Error(
163 'rest_guidelines_exists',
164 __( 'Guidelines already exist. Use PATCH to update.', 'gutenberg' ),
165 array( 'status' => 400 )
166 );
167 }
168
169 $content_term_id = Gutenberg_Guidelines_Post_Type::get_or_create_term_id(
170 Gutenberg_Guidelines_Post_Type::TERM_CONTENT,
171 __( 'Content', 'gutenberg' )
172 );
173 if ( is_wp_error( $content_term_id ) ) {
174 return $content_term_id;
175 }
176
177 $prepared = $this->prepare_item_for_database( $request );
178 $prepared->post_type = $this->post_type;
179 $prepared->post_title = __( 'Guidelines', 'gutenberg' );
180 $prepared->tax_input = array(
181 Gutenberg_Guidelines_Post_Type::TAXONOMY => array( $content_term_id ),
182 );
183
184 if ( ! isset( $prepared->post_status ) ) {
185 $prepared->post_status = 'draft';
186 }
187
188 $post_id = wp_insert_post( wp_slash( (array) $prepared ), true );
189
190 if ( is_wp_error( $post_id ) ) {
191 return $post_id;
192 }
193
194 if ( isset( $request['guideline_categories'] ) ) {
195 $categories = $this->sanitize_guideline_categories( $request['guideline_categories'] );
196 $this->save_guideline_categories_to_meta( $post_id, $categories );
197 }
198
199 $post = get_post( $post_id );
200
201 $request->set_param( 'context', 'edit' );
202 $response = $this->prepare_item_for_response( $post, $request );
203 $response = rest_ensure_response( $response );
204 $response->set_status( 201 );
205 $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $post_id ) ) );
206
207 return $response;
208 }
209
210 /**
211 * Updates guidelines.
212 *
213 * Saves guideline categories to meta before updating the post so that
214 * the revision captures the updated meta values.
215 *
216 * @param WP_REST_Request $request Full details about the request.
217 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error on failure.
218 */
219 public function update_item( $request ) {
220 $post = $this->get_post( $request['id'] );
221 if ( is_wp_error( $post ) ) {
222 return $post;
223 }
224
225 // Save guideline categories to meta first (so revision captures them).
226 if ( isset( $request['guideline_categories'] ) ) {
227 $categories = $this->sanitize_guideline_categories( $request['guideline_categories'] );
228 $this->save_guideline_categories_to_meta( $post->ID, $categories );
229 }
230
231 $prepared = $this->prepare_item_for_database( $request );
232 $prepared->ID = $post->ID;
233
234 // Trigger a post update to create a revision with the meta changes.
235 $result = wp_update_post( wp_slash( (array) $prepared ), true );
236
237 if ( is_wp_error( $result ) ) {
238 return $result;
239 }
240
241 $post = get_post( $post->ID );
242
243 $request->set_param( 'context', 'edit' );
244
245 return $this->prepare_item_for_response( $post, $request );
246 }
247
248 /**
249 * Prepares a single guidelines post for database.
250 *
251 * Returns a stdClass with standard post fields. Guideline categories
252 * are handled separately via save_guideline_categories_to_meta().
253 *
254 * @param WP_REST_Request $request Request object.
255 * @return stdClass Prepared post data.
256 */
257 protected function prepare_item_for_database( $request ) {
258 $prepared = new stdClass();
259
260 if ( isset( $request['id'] ) ) {
261 $prepared->ID = $request['id'];
262 }
263
264 if ( isset( $request['status'] ) ) {
265 $prepared->post_status = $request['status'];
266 }
267
268 return $prepared;
269 }
270
271 /**
272 * Prepares a single guidelines output for response.
273 *
274 * Builds the guideline_categories structured response from post meta
275 * and includes standard _links.
276 *
277 * @param WP_Post $post Post object.
278 * @param WP_REST_Request $request Request object.
279 * @return WP_REST_Response Response object.
280 */
281 public function prepare_item_for_response( $post, $request ) {
282 $fields = $this->get_fields_for_response( $request );
283 $data = array();
284
285 if ( rest_is_field_included( 'id', $fields ) ) {
286 $data['id'] = $post->ID;
287 }
288
289 if ( rest_is_field_included( 'status', $fields ) ) {
290 $data['status'] = $post->post_status;
291 }
292
293 if ( rest_is_field_included( 'guideline_categories', $fields ) ) {
294 $guideline_categories = Gutenberg_Guidelines_Post_Type::get_guideline_categories_from_meta( $post->ID );
295
296 // Handle ?block filter.
297 $block_filter = $request->get_param( 'block' );
298 if ( $block_filter && ! empty( $guideline_categories ) ) {
299 if ( isset( $guideline_categories['blocks'][ $block_filter ] ) ) {
300 $guideline_categories = array(
301 'blocks' => array(
302 $block_filter => $guideline_categories['blocks'][ $block_filter ],
303 ),
304 );
305 } else {
306 $guideline_categories = new stdClass();
307 }
308 } elseif ( $request->get_param( 'category' ) ) {
309 // Handle ?category filter.
310 $category_filter = $request->get_param( 'category' );
311 if ( isset( $guideline_categories[ $category_filter ] ) ) {
312 $guideline_categories = array(
313 $category_filter => $guideline_categories[ $category_filter ],
314 );
315 } else {
316 $guideline_categories = new stdClass();
317 }
318 }
319
320 if ( empty( $guideline_categories ) ) {
321 $guideline_categories = new stdClass();
322 }
323
324 $data['guideline_categories'] = $guideline_categories;
325 }
326
327 if ( rest_is_field_included( 'date', $fields ) ) {
328 $data['date'] = $this->prepare_date_response( $post->post_date_gmt, $post->post_date );
329 }
330
331 if ( rest_is_field_included( 'date_gmt', $fields ) ) {
332 $data['date_gmt'] = $this->prepare_date_response( $post->post_date_gmt );
333 }
334
335 if ( rest_is_field_included( 'modified', $fields ) ) {
336 $data['modified'] = $this->prepare_date_response( $post->post_modified_gmt, $post->post_modified );
337 }
338
339 if ( rest_is_field_included( 'modified_gmt', $fields ) ) {
340 $data['modified_gmt'] = $this->prepare_date_response( $post->post_modified_gmt );
341 }
342
343 if ( rest_is_field_included( 'author', $fields ) ) {
344 $data['author'] = (int) $post->post_author;
345 }
346
347 $context = ! empty( $request['context'] ) ? $request['context'] : 'view';
348 $data = $this->add_additional_fields_to_object( $data, $request );
349 $data = $this->filter_response_by_context( $data, $context );
350
351 $response = rest_ensure_response( $data );
352
353 if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
354 $response->add_links( $this->prepare_links( $post->ID ) );
355 }
356
357 return $response;
358 }
359
360 /**
361 * Prepares links for the request.
362 *
363 * Includes self, about, and version-history links.
364 *
365 * @param int $id Post ID.
366 * @return array Links for the given post.
367 */
368 protected function prepare_links( $id ) {
369 $base = sprintf( '%s/%s', $this->namespace, $this->rest_base );
370
371 $links = array(
372 'self' => array(
373 'href' => rest_url( trailingslashit( $base ) . $id ),
374 ),
375 'about' => array(
376 'href' => rest_url( 'wp/v2/types/' . $this->post_type ),
377 ),
378 );
379
380 if ( post_type_supports( $this->post_type, 'revisions' ) ) {
381 $revisions = wp_get_latest_revision_id_and_total_count( $id );
382 $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0;
383 $revisions_base = sprintf( '/%s/%d/revisions', $base, $id );
384
385 $links['version-history'] = array(
386 'href' => rest_url( $revisions_base ),
387 'count' => $revisions_count,
388 );
389 }
390
391 return $links;
392 }
393
394 /**
395 * Saves guideline categories to post meta.
396 *
397 * @param int $post_id Post ID.
398 * @param array $categories Sanitized guideline categories.
399 */
400 protected function save_guideline_categories_to_meta( $post_id, $categories ) {
401 // Save standard categories.
402 foreach ( Gutenberg_Guidelines_Post_Type::CATEGORY_META_KEYS as $category ) {
403 if ( isset( $categories[ $category ] ) ) {
404 $meta_key = '_guideline_' . $category;
405 $value = $categories[ $category ]['guidelines'] ?? '';
406 update_post_meta( $post_id, $meta_key, $value );
407 }
408 }
409
410 // Handle block-specific guidelines as individual meta keys.
411 if ( isset( $categories['blocks'] ) && is_array( $categories['blocks'] ) ) {
412 foreach ( $categories['blocks'] as $block_name => $block_data ) {
413 $meta_key = Gutenberg_Guidelines_Post_Type::block_name_to_meta_key( $block_name );
414 $value = $block_data['guidelines'] ?? '';
415
416 if ( ! empty( $value ) ) {
417 update_post_meta( $post_id, $meta_key, $value );
418 } else {
419 delete_post_meta( $post_id, $meta_key );
420 }
421 }
422 }
423 }
424
425 /**
426 * Sanitizes guideline categories data.
427 *
428 * @param mixed $categories Raw guideline categories from the request.
429 * @return array Sanitized guideline categories.
430 */
431 protected function sanitize_guideline_categories( $categories ) {
432 if ( ! is_array( $categories ) ) {
433 return array();
434 }
435
436 $valid_categories = Gutenberg_Guidelines_Post_Type::VALID_CATEGORIES;
437 $sanitized = array_intersect_key( $categories, array_flip( $valid_categories ) );
438
439 foreach ( $sanitized as $key => &$category ) {
440 if ( ! is_array( $category ) ) {
441 unset( $sanitized[ $key ] );
442 continue;
443 }
444
445 if ( 'blocks' === $key ) {
446 $category = $this->sanitize_blocks_category( $category );
447 } else {
448 $category = $this->sanitize_standard_category( $category );
449 }
450 }
451 unset( $category );
452
453 return $sanitized;
454 }
455
456 /**
457 * Sanitizes a standard (non-blocks) guideline category.
458 *
459 * @param array $category Raw category data.
460 * @return array Sanitized category data.
461 */
462 private function sanitize_standard_category( $category ) {
463 $sanitized = array_intersect_key( $category, array_flip( array( 'label', 'guidelines' ) ) );
464
465 foreach ( $sanitized as $key => &$value ) {
466 $value = is_string( $value ) ? sanitize_textarea_field( $value ) : '';
467 $max = 'label' === $key ? self::MAX_LABEL_LENGTH : self::MAX_GUIDELINE_LENGTH;
468 if ( mb_strlen( $value, 'UTF-8' ) > $max ) {
469 $value = mb_substr( $value, 0, $max, 'UTF-8' );
470 }
471 }
472 unset( $value );
473
474 return $sanitized;
475 }
476
477 /**
478 * Sanitizes the blocks guideline category.
479 *
480 * @param array $blocks Raw blocks category data.
481 * @return array Sanitized blocks category data.
482 */
483 private function sanitize_blocks_category( $blocks ) {
484 $sanitized = array();
485
486 foreach ( $blocks as $block_name => $block_data ) {
487 // Matches the block name validation in WP_Block_Type_Registry::register().
488 if ( ! is_string( $block_name ) || ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $block_name ) ) {
489 continue;
490 }
491
492 if ( ! is_array( $block_data ) ) {
493 continue;
494 }
495
496 $sanitized_block = array_intersect_key( $block_data, array_flip( array( 'guidelines' ) ) );
497
498 if ( isset( $sanitized_block['guidelines'] ) ) {
499 $sanitized_block['guidelines'] = is_string( $sanitized_block['guidelines'] )
500 ? sanitize_textarea_field( $sanitized_block['guidelines'] )
501 : '';
502 if ( mb_strlen( $sanitized_block['guidelines'], 'UTF-8' ) > self::MAX_GUIDELINE_LENGTH ) {
503 $sanitized_block['guidelines'] = mb_substr( $sanitized_block['guidelines'], 0, self::MAX_GUIDELINE_LENGTH, 'UTF-8' );
504 }
505 }
506
507 $sanitized[ $block_name ] = $sanitized_block;
508 }
509
510 return $sanitized;
511 }
512
513 /**
514 * Gets the single guidelines post.
515 *
516 * @param string|null $status_filter Optional. Filter by status ('publish' or 'draft').
517 * @return WP_Post|null The guidelines post or null if not found.
518 */
519 protected function get_guidelines_post( $status_filter = null ) {
520 $post_status = array( 'publish', 'draft' );
521
522 if ( $status_filter ) {
523 $post_status = $status_filter;
524 }
525
526 $posts = get_posts(
527 array(
528 'post_type' => $this->post_type,
529 'post_status' => $post_status,
530 'posts_per_page' => 1,
531 'orderby' => 'date',
532 'order' => 'DESC',
533 'no_found_rows' => true,
534 'tax_query' => array(
535 array(
536 'taxonomy' => Gutenberg_Guidelines_Post_Type::TAXONOMY,
537 'field' => 'slug',
538 'terms' => Gutenberg_Guidelines_Post_Type::TERM_CONTENT,
539 ),
540 ),
541 )
542 );
543
544 return ! empty( $posts ) ? $posts[0] : null;
545 }
546
547 /**
548 * Retrieves the guidelines schema, conforming to JSON Schema.
549 *
550 * @return array Item schema data.
551 */
552 public function get_item_schema() {
553 if ( $this->schema ) {
554 return $this->add_additional_fields_schema( $this->schema );
555 }
556
557 $this->schema = array(
558 '$schema' => 'http://json-schema.org/draft-04/schema#',
559 'title' => 'guidelines',
560 'type' => 'object',
561 'properties' => array(
562 'id' => array(
563 'description' => __( 'Unique identifier for the guidelines.', 'gutenberg' ),
564 'type' => 'integer',
565 'context' => array( 'view', 'edit' ),
566 'readonly' => true,
567 ),
568 'status' => array(
569 'description' => __( 'The status of the guidelines (draft or publish).', 'gutenberg' ),
570 'type' => 'string',
571 'enum' => Gutenberg_Guidelines_Post_Type::VALID_STATUSES,
572 'context' => array( 'view', 'edit' ),
573 ),
574 'guideline_categories' => array(
575 'description' => __( 'The guideline categories and their content.', 'gutenberg' ),
576 'type' => 'object',
577 'context' => array( 'view', 'edit' ),
578 'arg_options' => array(
579 'validate_callback' => static function ( $value ) {
580 if ( ! is_array( $value ) && ! is_object( $value ) ) {
581 return new WP_Error(
582 'rest_invalid_param',
583 __( 'guideline_categories must be a JSON object.', 'gutenberg' ),
584 array( 'status' => 400 )
585 );
586 }
587 return true;
588 },
589 'sanitize_callback' => static function ( $value ) {
590 return (array) $value;
591 },
592 ),
593 'properties' => array(
594 'copy' => array(
595 'type' => 'object',
596 'properties' => array(
597 'label' => array(
598 'type' => 'string',
599 'maxLength' => self::MAX_LABEL_LENGTH,
600 ),
601 'guidelines' => array(
602 'type' => 'string',
603 'maxLength' => self::MAX_GUIDELINE_LENGTH,
604 ),
605 ),
606 ),
607 'images' => array(
608 'type' => 'object',
609 'properties' => array(
610 'label' => array(
611 'type' => 'string',
612 'maxLength' => self::MAX_LABEL_LENGTH,
613 ),
614 'guidelines' => array(
615 'type' => 'string',
616 'maxLength' => self::MAX_GUIDELINE_LENGTH,
617 ),
618 ),
619 ),
620 'site' => array(
621 'type' => 'object',
622 'properties' => array(
623 'label' => array(
624 'type' => 'string',
625 'maxLength' => self::MAX_LABEL_LENGTH,
626 ),
627 'guidelines' => array(
628 'type' => 'string',
629 'maxLength' => self::MAX_GUIDELINE_LENGTH,
630 ),
631 ),
632 ),
633 'blocks' => array(
634 'type' => 'object',
635 'additionalProperties' => array(
636 'type' => 'object',
637 'properties' => array(
638 'guidelines' => array(
639 'type' => 'string',
640 'maxLength' => self::MAX_GUIDELINE_LENGTH,
641 ),
642 ),
643 ),
644 ),
645 'additional' => array(
646 'type' => 'object',
647 'properties' => array(
648 'label' => array(
649 'type' => 'string',
650 'maxLength' => self::MAX_LABEL_LENGTH,
651 ),
652 'guidelines' => array(
653 'type' => 'string',
654 'maxLength' => self::MAX_GUIDELINE_LENGTH,
655 ),
656 ),
657 ),
658 ),
659 ),
660 'date' => array(
661 'description' => __( 'The date the guidelines were created, in the site\'s timezone.', 'gutenberg' ),
662 'type' => 'string',
663 'format' => 'date-time',
664 'context' => array( 'view', 'edit' ),
665 'readonly' => true,
666 ),
667 'date_gmt' => array(
668 'description' => __( 'The date the guidelines were created, as GMT.', 'gutenberg' ),
669 'type' => 'string',
670 'format' => 'date-time',
671 'context' => array( 'view', 'edit' ),
672 'readonly' => true,
673 ),
674 'modified' => array(
675 'description' => __( 'The date the guidelines were last modified, in the site\'s timezone.', 'gutenberg' ),
676 'type' => 'string',
677 'format' => 'date-time',
678 'context' => array( 'view', 'edit' ),
679 'readonly' => true,
680 ),
681 'modified_gmt' => array(
682 'description' => __( 'The date the guidelines were last modified, as GMT.', 'gutenberg' ),
683 'type' => 'string',
684 'format' => 'date-time',
685 'context' => array( 'view', 'edit' ),
686 'readonly' => true,
687 ),
688 'author' => array(
689 'description' => __( 'The ID of the author of the guidelines.', 'gutenberg' ),
690 'type' => 'integer',
691 'context' => array( 'view', 'edit' ),
692 'readonly' => true,
693 ),
694 ),
695 );
696
697 return $this->add_additional_fields_schema( $this->schema );
698 }
699 }
700