post_type, self::CODE_TYPES, true ) ) { return new \WP_Error( 'code_post_type', "Post $id is a code snippet and cannot be changed here." ); } if ( $types && ! in_array( $post->post_type, $types, true ) ) { return new \WP_Error( 'invalid_post_type', "Post $id is a {$post->post_type}, expected: " . implode( ', ', $types ) . '.' ); } $cap = 'delete' === $action ? 'delete_post' : ( 'read' === $action ? 'read_post' : 'edit_post' ); if ( ! current_user_can( $cap, $post->ID ) ) { return new \WP_Error( 'forbidden', "You cannot $action post $id." ); } return $post; } /** * Post-type level permission check for create/update with a status. * * @param string $post_type Post type. * @param int $post_id 0 for create. * @param string $status Target status ('' to keep). * @return true|\WP_Error */ public static function can_write_type( string $post_type, int $post_id, string $status = '' ) { if ( in_array( $post_type, self::CODE_TYPES, true ) || ( $post_id && in_array( get_post_type( $post_id ), self::CODE_TYPES, true ) ) ) { return new \WP_Error( 'code_post_type', 'Code snippets cannot be changed here.' ); } $pt = get_post_type_object( $post_type ); if ( ! $pt ) { return new \WP_Error( 'invalid_post_type', "Unknown post type: $post_type" ); } if ( ! $post_id && ! current_user_can( $pt->cap->create_posts ) ) { return new \WP_Error( 'forbidden', "You cannot create {$pt->labels->name}." ); } if ( $post_id && ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'forbidden', "You cannot edit post $post_id." ); } if ( in_array( $status, array( 'publish', 'private', 'future' ), true ) && ! current_user_can( $pt->cap->publish_posts ) ) { return new \WP_Error( 'forbidden_publish', "You cannot publish {$pt->labels->name}." ); } return true; } /** * Internal post types that the generic post tools never create, update or * copy. Their own tools (media, menus, Site Editor) handle them. */ const INTERNAL_TYPES = array( 'attachment', 'revision', 'nav_menu_item', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_global_styles', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_font_family', 'wp_font_face' ); /** * May the generic post tools (create, update, duplicate) work on this post type? * Internal types and types without an admin screen are refused. * * @param string $post_type Post type. * @return true|\WP_Error */ public static function generic_type( string $post_type ) { $pt = get_post_type_object( $post_type ); if ( ! $pt ) { return new \WP_Error( 'invalid_post_type', 'Unknown post type: ' . $post_type ); } if ( in_array( $post_type, self::INTERNAL_TYPES, true ) || in_array( $post_type, self::CODE_TYPES, true ) || ! $pt->show_ui ) { return new \WP_Error( 'unsupported_post_type', 'Items of type ' . $post_type . ' cannot be changed with this tool. Use the dedicated tool (media, menus or the builder tools).' ); } return true; } /** * Keep the page template when calling wp_update_post (otherwise core may * reset or reject it). * * @param array $postarr Post array. * @return array */ public static function keep_template( array $postarr ): array { if ( ! empty( $postarr['ID'] ) && ! isset( $postarr['page_template'] ) ) { $tpl = get_post_meta( (int) $postarr['ID'], '_wp_page_template', true ); if ( $tpl ) { $postarr['page_template'] = $tpl; } } return $postarr; } /** * Last change time of a post (UTC, or site time for posts WordPress * never stamped in UTC). * * @param \WP_Post $p Post. */ public static function modified( \WP_Post $p ): string { return ( $p->post_modified_gmt && '0000-00-00 00:00:00' !== $p->post_modified_gmt ) ? $p->post_modified_gmt : $p->post_modified; } /** * Short post summary. * * @param \WP_Post $p Post. * @return array */ public static function summary( \WP_Post $p ): array { return array( 'id' => $p->ID, 'type' => $p->post_type, 'title' => get_the_title( $p ), 'status' => $p->post_status, 'slug' => $p->post_name, 'url' => get_permalink( $p ), 'modified' => self::modified( $p ), 'builder' => self::builder_of( $p->ID ), 'edit_url' => get_edit_post_link( $p->ID, 'raw' ), ); } /** * Which builder owns this post's layout. * * @param int $id Post id. */ public static function builder_of( int $id ): string { if ( 'builder' === get_post_meta( $id, '_elementor_edit_mode', true ) ) { return 'elementor'; } if ( 'bricks' === get_post_meta( $id, '_bricks_editor_mode', true ) ) { return 'bricks'; } if ( get_post_meta( $id, '_fl_builder_enabled', true ) ) { return 'beaver'; } if ( 'on' === get_post_meta( $id, '_et_pb_use_builder', true ) ) { return 'on' === get_post_meta( $id, '_et_pb_use_divi_5', true ) ? 'divi5' : 'divi'; } if ( get_post_meta( $id, '_breakdance_data', true ) ) { return defined( '__OXYGEN_VERSION' ) || defined( 'OXYGEN_VERSION' ) && ! defined( '__BREAKDANCE_VERSION' ) ? 'oxygen' : 'breakdance'; } if ( get_post_meta( $id, '_oxygen_data', true ) ) { return 'oxygen'; } if ( get_post_meta( $id, 'ct_builder_json', true ) || get_post_meta( $id, 'ct_builder_shortcodes', true ) ) { return 'oxygen_classic'; } $content = (string) get_post_field( 'post_content', $id, 'raw' ); if ( 'active' === get_post_meta( $id, 'fusion_builder_status', true ) || false !== strpos( $content, '[fusion_builder_container' ) ) { return 'avada'; } if ( 'true' === get_post_meta( $id, '_wpb_vc_js_status', true ) || false !== strpos( $content, '[vc_row' ) ) { return 'wpbakery'; } if ( function_exists( 'has_blocks' ) && has_blocks( $content ) ) { return 'gutenberg'; } return '' === trim( $content ) ? 'empty' : 'classic'; } /** * HTML allowed for the current user. * * @param string $html HTML. */ public static function kses( string $html ): string { return current_user_can( 'unfiltered_html' ) ? $html : wp_kses_post( $html ); } /** * Util::kses on every string in a nested array or object. * * @param mixed $value Value. * @return mixed */ public static function kses_deep( $value ) { if ( current_user_can( 'unfiltered_html' ) ) { return $value; } if ( is_string( $value ) ) { return wp_kses_post( $value ); } if ( is_array( $value ) ) { foreach ( $value as $k => $v ) { $value[ $k ] = self::kses_deep( $v ); } return $value; } if ( is_object( $value ) ) { foreach ( get_object_vars( $value ) as $k => $v ) { $value->$k = self::kses_deep( $v ); } } return $value; } /** * Util::kses on a JSON string: every string value inside the decoded data * is filtered, then the JSON is encoded again. Non-JSON falls back to kses. * * @param string $json JSON text. */ public static function kses_json( string $json ): string { if ( current_user_can( 'unfiltered_html' ) ) { return $json; } $data = json_decode( $json, true ); if ( null === $data && 'null' !== trim( $json ) ) { return wp_kses_post( $json ); } $enc = wp_json_encode( self::kses_deep( $data ) ); return false === $enc ? '' : $enc; } /** * Purge one post through whichever cache plugin the site uses. Never * configures caching, only asks the installed plugin to purge. * * @param int $id Post id. * @return string[] Plugins that were asked to purge. */ public static function purge_post( int $id ): array { $done = array(); clean_post_cache( $id ); // Builder CSS and asset caches for this post. if ( class_exists( '\ET_Core_PageResource' ) && method_exists( '\ET_Core_PageResource', 'remove_static_resources' ) ) { \ET_Core_PageResource::remove_static_resources( $id, 'all' ); $done[] = 'Divi static CSS'; } if ( class_exists( '\FLBuilderModel' ) && method_exists( '\FLBuilderModel', 'delete_all_asset_cache' ) && get_post_meta( $id, '_fl_builder_enabled', true ) ) { \FLBuilderModel::delete_all_asset_cache( $id ); $done[] = 'Beaver Builder assets'; } if ( class_exists( '\UAGB_Helper' ) && method_exists( '\UAGB_Helper', 'delete_page_assets' ) ) { \UAGB_Helper::delete_page_assets( $id ); $done[] = 'Spectra assets'; } $url = (string) get_permalink( $id ); if ( defined( 'LSCWP_V' ) ) { do_action( 'litespeed_purge_post', $id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. $done[] = 'LiteSpeed Cache'; } if ( function_exists( 'rocket_clean_post' ) ) { rocket_clean_post( $id ); $done[] = 'WP Rocket'; } if ( function_exists( 'w3tc_flush_post' ) ) { w3tc_flush_post( $id ); $done[] = 'W3 Total Cache'; } if ( function_exists( 'wpsc_delete_post_cache' ) ) { wpsc_delete_post_cache( $id ); $done[] = 'WP Super Cache'; } if ( function_exists( 'sg_cachepress_purge_cache' ) && $url ) { sg_cachepress_purge_cache( $url ); $done[] = 'SiteGround Optimizer'; } if ( class_exists( '\WPO_Page_Cache' ) && method_exists( '\WPO_Page_Cache', 'delete_single_post_cache' ) ) { \WPO_Page_Cache::delete_single_post_cache( $id ); $done[] = 'WP-Optimize'; } if ( has_action( 'breeze_clear_all_cache' ) ) { do_action( 'breeze_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. $done[] = 'Breeze'; } if ( function_exists( 'nitropack_invalidate' ) && $url ) { nitropack_invalidate( $url ); $done[] = 'NitroPack'; } if ( class_exists( '\FlyingPress\Purge' ) && method_exists( '\FlyingPress\Purge', 'purge_url' ) && $url ) { \FlyingPress\Purge::purge_url( $url ); $done[] = 'FlyingPress'; } return $done; } /** * Fetch the page on the front end and report the HTTP status. * * @param int $id Post id. * @return array */ public static function render_check( int $id ): array { $post = get_post( $id ); if ( ! $post || 'publish' !== $post->post_status ) { return array( 'checked' => false, 'reason' => 'Not published, front end not checked.' ); } if ( 'cli-server' === PHP_SAPI ) { return array( 'checked' => false, 'reason' => 'Single-threaded development server: loopback requests are not possible.' ); } $res = \HandyMCP\Support\Env::loopback( (string) get_permalink( $id ) ); return array( 'checked' => true, 'http' => $res['status'], 'ok' => $res['ok'] && ! \HandyMCP\Support\Env::has_fatal_markers( $res['body'] ), 'error' => $res['error'] ?? null, ); } /** * Schema helper: integer id property. * * @param string $description Description. * @return array */ public static function id( string $description = 'Post ID' ): array { return array( 'type' => 'integer', 'minimum' => 1, 'description' => $description ); } /** * Schema helper: post status. * * @return array */ public static function status_prop(): array { return array( 'type' => 'string', 'enum' => array( 'draft', 'publish', 'pending', 'private', 'future' ), 'description' => 'Post status. Defaults to draft for new content.' ); } /** * Quality checks on content the AI wrote. * * @param string $text Content (JSON or HTML). * @return string[] */ public static function content_warnings( string $text ): array { $out = array(); if ( preg_match( '/[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}\x{1F000}-\x{1F2FF}\x{2B50}\x{2B55}\x{2705}\x{274C}\x{2728}]/u', $text ) ) { $out[] = 'Emoji found in the content. Do not use emoji on pages: use the builder icon widgets (Elementor "icon", "icon-box", "icon-list" with Font Awesome, e.g. {"value":"fas fa-truck","library":"fa-solid"}, or the theme icons) or upload an SVG with icons_add_svg, then save again.'; } if ( preg_match( '/\[(products|product_page|product_category|product_categories|recent_products|featured_products|sale_products|best_selling_products|top_rated_products|add_to_cart|woocommerce_[a-z_]+)[\s\]]/', $text ) && ( class_exists( '\\Elementor\\Plugin' ) || wp_is_block_theme() ) ) { $out[] = 'WooCommerce shortcode used. Prefer the product widgets of the page builder or theme (for example the Elementor Pro "woocommerce-products" widget, or WooCommerce blocks in Gutenberg). Check elementor_widgets for the right widget.'; } return $out; } }