*/ public static function libraries(): array { return array( 'core' => 'WordPress core', 'kadence' => 'Kadence Blocks', 'generateblocks' => 'GenerateBlocks', 'uagb' => 'Spectra', 'stackable' => 'Stackable', 'ugb' => 'Stackable (v1)', 'themeisle-blocks' => 'Otter Blocks', 'blocksy' => 'Blocksy', 'woocommerce' => 'WooCommerce', 'gutenkit' => 'GutenKit', 'greenshift-blocks' => 'Greenshift', ); } /** * Register. * * @param Registry $r Registry. */ public static function register( Registry $r ): void { $libs = array(); $present = array(); if ( class_exists( '\WP_Block_Type_Registry' ) ) { foreach ( array_keys( \WP_Block_Type_Registry::get_instance()->get_all_registered() ) as $name ) { $ns = strtok( $name, '/' ); $present[ $ns ] = ( $present[ $ns ] ?? 0 ) + 1; } } foreach ( self::libraries() as $ns => $label ) { if ( 'core' !== $ns && ! empty( $present[ $ns ] ) ) { $libs[ $label ] = $present[ $ns ] . ' blocks'; } } $r->group( 'gutenberg', array( 'label' => 'Gutenberg (Blocks)', 'description' => 'Read and edit block content of any post, page, reusable block, template part or theme element. Includes block libraries: ' . ( $libs ? implode( ', ', array_keys( $libs ) ) : 'core blocks only' ) . '.', 'detected' => function_exists( 'parse_blocks' ), 'plugins' => array_merge( array( 'Block editor' => get_bloginfo( 'version' ) ), $libs ), ) ); $r->add( array( 'name' => 'gutenberg_block_types', 'group' => 'gutenberg', 'title' => 'List block types', 'description' => 'Registered block types grouped by library (core, kadence, generateblocks, uagb/Spectra, stackable, blocksy...). Pass name to get one block\'s attributes, supports and example markup hints.', 'cap' => 'edit_posts', 'input' => array( 'properties' => array( 'namespace' => array( 'type' => 'string', 'description' => 'Only this namespace, e.g. kadence or core.' ), 'name' => array( 'type' => 'string', 'description' => 'Full block name, e.g. core/heading, for details.' ), ), ), 'run' => static function ( array $a ) { $reg = \WP_Block_Type_Registry::get_instance(); if ( ! empty( $a['name'] ) ) { $t = $reg->get_registered( $a['name'] ); if ( ! $t ) { return new \WP_Error( 'unknown_block', 'Block type not registered: ' . $a['name'] ); } return array( 'name' => $t->name, 'title' => $t->title, 'description' => $t->description, 'category' => $t->category, 'dynamic' => $t->is_dynamic(), 'parent' => $t->parent, 'attributes' => $t->attributes, 'supports' => $t->supports, 'note' => $t->is_dynamic() ? 'Dynamic block: rendered by PHP, save it as a self-closing comment with attributes only.' : 'Static block: the saved HTML must match what the block\'s save() produces, otherwise the editor shows "invalid content". Copy the markup of an existing block of this type when unsure.', ); } $out = array(); foreach ( $reg->get_all_registered() as $name => $t ) { $ns = strtok( $name, '/' ); if ( ! empty( $a['namespace'] ) && $ns !== $a['namespace'] ) { continue; } $out[ $ns ][] = array( 'name' => $name, 'title' => $t->title, 'dynamic' => $t->is_dynamic() ); } ksort( $out ); return array( 'libraries' => self::libraries(), 'blocks' => $out ); }, ) ); $r->add( array( 'name' => 'gutenberg_get_blocks', 'group' => 'gutenberg', 'title' => 'Get block content', 'description' => 'Block tree of a post (any post type: page, post, wp_block, wp_template_part, gp_elements, ct_content_block...). Each block has a path like "0.2.1" used by the other block tools, its name, attributes and inner HTML. Pass markup=true to also get the raw markup (needed before gutenberg_save_content), or raw_only for the markup alone.', 'cap' => 'edit_posts', 'raw' => true, 'input' => array( 'required' => array( 'id' ), 'properties' => array( 'id' => Util::id(), 'markup' => array( 'type' => 'boolean', 'default' => false ), 'raw_only' => array( 'type' => 'boolean', 'default' => false ), ), ), 'run' => static function ( array $a ) { $p = Util::post( $a['id'] ); if ( is_wp_error( $p ) ) { return $p; } $out = array( 'id' => $p->ID, 'title' => $p->post_title, 'type' => $p->post_type, 'builder' => Util::builder_of( $p->ID ), 'modified' => Util::modified( $p ) ); if ( $a['raw_only'] || $a['markup'] ) { $out['content'] = $p->post_content; } if ( ! $a['raw_only'] ) { $out['blocks'] = self::tree( parse_blocks( $p->post_content ), '' ); } return $out; }, ) ); $r->add( array( 'name' => 'gutenberg_save_content', 'live_guard' => 'layout', 'group' => 'gutenberg', 'title' => 'Save block content (create or replace)', 'description' => 'Create a page/post with block markup, or replace the block content of an existing one. content must be serialized block markup (html). The markup is parsed and re-serialized by WordPress; unknown block types and broken comments are reported. Leave id empty to create.', 'write' => true, 'cap' => 'edit_posts', 'input' => array( 'required' => array( 'content' ), 'properties' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1 ), 'title' => array( 'type' => 'string', 'maxLength' => 500 ), 'post_type' => array( 'type' => 'string', 'default' => 'page' ), 'status' => Util::status_prop(), 'template' => array( 'type' => 'string' ), 'content' => array( 'type' => 'string', 'minLength' => 1 ), 'allow_shrink' => array( 'type' => 'boolean', 'description' => 'Only when the user wants most of the existing content removed.' ), 'remove_builder' => array( 'type' => 'boolean', 'description' => 'Only when the user agreed: switch an Elementor page back to blocks. The Elementor layout is kept in the database, so it can be switched back in Elementor.' ), ), ), 'run' => array( __CLASS__, 'save_content' ), ) ); $r->add( array( 'name' => 'gutenberg_update_block', 'group' => 'gutenberg', 'title' => 'Update one block', 'description' => 'Change one block by path: merge attributes (set a key to null to remove it) and/or replace its inner HTML. For static blocks keep the HTML consistent with the attributes.', 'write' => true, 'cap' => 'edit_posts', 'input' => array( 'required' => array( 'id', 'path' ), 'properties' => array( 'id' => Util::id(), 'path' => array( 'type' => 'string', 'pattern' => '^\d+(\.\d+)*$', 'description' => 'Block path from gutenberg_get_blocks, e.g. 0.2' ), 'attrs' => array( 'type' => 'object', 'additionalProperties' => true ), 'html' => array( 'type' => 'string', 'description' => 'New inner HTML of this block (only for blocks without inner blocks).' ), ), ), 'run' => static function ( array $a ) { return self::mutate( $a['id'], static function ( array &$blocks ) use ( $a ) { $b = &self::at( $blocks, $a['path'] ); if ( null === $b ) { return new \WP_Error( 'block_not_found', 'No block at path ' . $a['path'] ); } if ( isset( $a['attrs'] ) ) { foreach ( self::strip_code_attrs( (array) $a['attrs'] ) as $k => $v ) { if ( null === $v ) { unset( $b['attrs'][ $k ] ); } else { $b['attrs'][ $k ] = $v; } } } if ( isset( $a['html'] ) ) { if ( ! empty( $b['innerBlocks'] ) ) { return new \WP_Error( 'has_inner_blocks', 'This block has inner blocks. Update those instead, or replace the whole block with gutenberg_insert_blocks + gutenberg_remove_block.' ); } $html = Util::kses( $a['html'] ); $b['innerHTML'] = $html; $b['innerContent'] = array( $html ); } return 'Block ' . $a['path'] . ' updated.'; } ); }, ) ); $r->add( array( 'name' => 'gutenberg_insert_blocks', 'group' => 'gutenberg', 'title' => 'Insert blocks', 'description' => 'Insert block markup at a position. parent_path empty = top level; position = index among the parent\'s children (omit to append).', 'write' => true, 'cap' => 'edit_posts', 'input' => array( 'required' => array( 'id', 'markup' ), 'properties' => array( 'id' => Util::id(), 'markup' => array( 'type' => 'string', 'minLength' => 1, 'description' => 'Serialized block markup to insert.' ), 'parent_path' => array( 'type' => 'string', 'pattern' => '^(\d+(\.\d+)*)?$', 'default' => '' ), 'position' => array( 'type' => 'integer', 'minimum' => 0 ), ), ), 'run' => static function ( array $a ) { $new = array_values( array_filter( self::strip_code_blocks( parse_blocks( Util::kses( $a['markup'] ) ) ), static function ( $b ) { return null !== $b['blockName'] || '' !== trim( (string) $b['innerHTML'] ); } ) ); if ( ! $new ) { return new \WP_Error( 'no_blocks', 'markup contains no blocks.' ); } return self::mutate( $a['id'], static function ( array &$blocks ) use ( $a, $new ) { if ( '' === $a['parent_path'] ) { $pos = isset( $a['position'] ) ? min( $a['position'], count( $blocks ) ) : count( $blocks ); array_splice( $blocks, $pos, 0, $new ); return count( $new ) . ' block(s) inserted at top level position ' . $pos . '.'; } $parent = &self::at( $blocks, $a['parent_path'] ); if ( null === $parent ) { return new \WP_Error( 'block_not_found', 'No block at path ' . $a['parent_path'] ); } $pos = isset( $a['position'] ) ? min( $a['position'], count( $parent['innerBlocks'] ) ) : count( $parent['innerBlocks'] ); self::insert_inner( $parent, $pos, $new ); return count( $new ) . ' block(s) inserted into ' . $a['parent_path'] . ' at ' . $pos . '.'; } ); }, ) ); $r->add( array( 'name' => 'gutenberg_remove_block', 'group' => 'gutenberg', 'title' => 'Remove a block', 'write' => true, 'cap' => 'edit_posts', 'input' => array( 'required' => array( 'id', 'path' ), 'properties' => array( 'id' => Util::id(), 'path' => array( 'type' => 'string', 'pattern' => '^\d+(\.\d+)*$' ), ), ), 'run' => static function ( array $a ) { return self::mutate( $a['id'], static function ( array &$blocks ) use ( $a ) { $parts = explode( '.', $a['path'] ); $index = (int) array_pop( $parts ); if ( ! $parts ) { if ( ! isset( $blocks[ $index ] ) ) { return new \WP_Error( 'block_not_found', 'No block at path ' . $a['path'] ); } array_splice( $blocks, $index, 1 ); return 'Block removed.'; } $parent = &self::at( $blocks, implode( '.', $parts ) ); if ( null === $parent || ! isset( $parent['innerBlocks'][ $index ] ) ) { return new \WP_Error( 'block_not_found', 'No block at path ' . $a['path'] ); } self::remove_inner( $parent, $index ); return 'Block removed.'; } ); }, ) ); } /** * Simplified tree with paths. * * @param array> $blocks Parsed blocks. * @param string $prefix Path prefix. * @return array> */ public static function tree( array $blocks, string $prefix ): array { $out = array(); $i = 0; foreach ( $blocks as $b ) { if ( null === $b['blockName'] && '' === trim( (string) $b['innerHTML'] ) ) { ++$i; continue; } $path = '' === $prefix ? (string) $i : $prefix . '.' . $i; $item = array( 'path' => $path, 'name' => $b['blockName'] ? $b['blockName'] : 'freeform (classic HTML)', 'attrs' => $b['attrs'] ? $b['attrs'] : new \stdClass() ); if ( ! empty( $b['innerBlocks'] ) ) { $item['innerBlocks'] = self::tree( $b['innerBlocks'], $path ); } else { $item['html'] = trim( (string) $b['innerHTML'] ); } $out[] = $item; ++$i; } return $out; } /** * Reference to the block at a path. * * @param array> $blocks Blocks. * @param string $path Path. * @return array|null */ public static function &at( array &$blocks, string $path ) { $null = null; $level = &$blocks; $parts = explode( '.', $path ); foreach ( $parts as $n => $idx ) { $idx = (int) $idx; if ( ! isset( $level[ $idx ] ) ) { return $null; } if ( $n === count( $parts ) - 1 ) { return $level[ $idx ]; } $level = &$level[ $idx ]['innerBlocks']; } return $null; } /** * Insert inner blocks and keep innerContent placeholders in step. * * @param array $parent Parent block (by ref). * @param int $pos Index among inner blocks. * @param array> $new Blocks. */ private static function insert_inner( array &$parent, int $pos, array $new ): void { // Find the innerContent index of the pos-th null placeholder. $seen = 0; $target = null; foreach ( $parent['innerContent'] as $i => $chunk ) { if ( null === $chunk ) { if ( $seen === $pos ) { $target = $i; break; } ++$seen; } } $placeholders = array_fill( 0, count( $new ), null ); if ( null === $target ) { // Append after the last placeholder, or before the closing wrapper HTML. $last = null; foreach ( $parent['innerContent'] as $i => $chunk ) { if ( null === $chunk ) { $last = $i; } } if ( null !== $last ) { array_splice( $parent['innerContent'], $last + 1, 0, $placeholders ); } elseif ( count( $parent['innerContent'] ) >= 2 ) { array_splice( $parent['innerContent'], 1, 0, $placeholders ); } else { $parent['innerContent'] = array_merge( $parent['innerContent'], $placeholders ); } } else { array_splice( $parent['innerContent'], $target, 0, $placeholders ); } array_splice( $parent['innerBlocks'], $pos, 0, $new ); } /** * Remove one inner block and its placeholder. * * @param array $parent Parent (by ref). * @param int $index Inner block index. */ private static function remove_inner( array &$parent, int $index ): void { $seen = 0; foreach ( $parent['innerContent'] as $i => $chunk ) { if ( null === $chunk ) { if ( $seen === $index ) { array_splice( $parent['innerContent'], $i, 1 ); break; } ++$seen; } } array_splice( $parent['innerBlocks'], $index, 1 ); } /** * Parse, change, serialize and save. * * @param int $id Post id. * @param callable $change Receives blocks by reference; returns message or WP_Error. * @return array|\WP_Error */ private static function mutate( int $id, callable $change ) { $p = Util::post( $id ); if ( is_wp_error( $p ) ) { return $p; } $builder = Util::builder_of( $p->ID ); if ( in_array( $builder, array( 'elementor', 'bricks', 'beaver', 'divi', 'divi5', 'breakdance', 'oxygen', 'oxygen_classic', 'wpbakery', 'avada' ), true ) ) { return new \WP_Error( 'builder_page', "This page is built with $builder, not blocks. Use the $builder tools." ); } $blocks = parse_blocks( $p->post_content ); $message = $change( $blocks ); if ( is_wp_error( $message ) ) { return $message; } return self::write( $p->ID, serialize_blocks( $blocks ), array(), (string) $message ); } /** * Create or replace content. * * @param array $a Args. * @return array|\WP_Error */ public static function save_content( array $a ) { $id = (int) ( $a['id'] ?? 0 ); if ( $id ) { $p = Util::post( $id ); if ( is_wp_error( $p ) ) { return $p; } $builder = Util::builder_of( $p->ID ); $drop_builder = 'elementor' === $builder && ! empty( $a['remove_builder'] ); if ( ! $drop_builder && in_array( $builder, array( 'elementor', 'bricks', 'beaver', 'divi', 'divi5', 'breakdance', 'oxygen', 'oxygen_classic' ), true ) ) { $how = 'elementor' === $builder ? ' To switch this page to blocks on purpose, ask the user and call again with remove_builder=true.' : ''; return new \WP_Error( 'builder_page', "This page is built with $builder. Saving block content would not change what visitors see. Use the $builder tools instead.$how" ); } } $ok = Util::can_write_type( $id ? get_post_type( $id ) : $a['post_type'], $id, (string) ( $a['status'] ?? '' ) ); if ( is_wp_error( $ok ) ) { return $ok; } if ( isset( $a['template'] ) ) { $tpl_ok = self::check_template( (string) $a['template'], $id ? (string) get_post_type( $id ) : (string) $a['post_type'] ); if ( is_wp_error( $tpl_ok ) ) { return $tpl_ok; } } $content = Util::kses( $a['content'] ); $warnings = self::validate( $content ); $blocks = self::strip_code_blocks( parse_blocks( $content ) ); $markup = serialize_blocks( $blocks ); // Guard against a cut-off reply wiping a page: refuse when most of the text or images would disappear. if ( $id && empty( $a['allow_shrink'] ) ) { $old_html = (string) get_post_field( 'post_content', $id ); $old_text = mb_strlen( trim( (string) preg_replace( '/\s+/', ' ', wp_strip_all_tags( $old_html ) ) ) ); $new_text = mb_strlen( trim( (string) preg_replace( '/\s+/', ' ', wp_strip_all_tags( $markup ) ) ) ); $old_imgs = substr_count( strtolower( $old_html ), ' 300 && $new_text < $old_text * 0.35 ) || ( $old_imgs >= 3 && $new_imgs < $old_imgs * 0.35 ) ) { return new \WP_Error( 'content_shrink', sprintf( 'This save would remove most of the page (text %1$d to %2$d characters, images %3$d to %4$d). Nothing was saved. If the reply was cut off, send the full content. If removing it is intended, call again with allow_shrink=true.', $old_text, $new_text, $old_imgs, $new_imgs ) ); } } if ( ! $id ) { $id = wp_insert_post( wp_slash( array( 'post_type' => $a['post_type'], 'post_title' => sanitize_text_field( $a['title'] ?? 'Untitled' ), 'post_status' => 'draft', ) ), true ); if ( is_wp_error( $id ) ) { return $id; } $created = true; } $extra = array(); if ( isset( $a['title'] ) && empty( $created ) ) { $extra['post_title'] = sanitize_text_field( $a['title'] ); } $extra['post_status'] = $a['status'] ?? ( empty( $created ) ? get_post_status( $id ) : 'draft' ); if ( isset( $a['template'] ) ) { $extra['page_template'] = 'default' === $a['template'] ? '' : $a['template']; } $res = self::write( (int) $id, $markup, $extra, empty( $created ) ? 'Content replaced.' : 'Created.', $warnings ); if ( ! empty( $drop_builder ) && ! is_wp_error( $res ) ) { // Only after every check passed and the content was saved. Elementor renders only // while edit mode is "builder"; its data stays for a later switch back. delete_post_meta( (int) $id, '_elementor_edit_mode' ); } return $res; } /** * Save markup and verify it was stored. * * @param int $id Post id. * @param string $markup Block markup. * @param array $extra Extra post fields. * @param string $message Message. * @param string[] $warnings Warnings. * @return array|\WP_Error */ private static function write( int $id, string $markup, array $extra, string $message, array $warnings = array() ) { $res = wp_update_post( wp_slash( Util::keep_template( array_merge( array( 'ID' => $id, 'post_content' => $markup ), $extra ) ) ), true ); if ( is_wp_error( $res ) ) { return $res; } $stored = (string) get_post_field( 'post_content', $id, 'raw' ); if ( count( parse_blocks( $stored ) ) !== count( parse_blocks( $markup ) ) ) { $warnings[] = 'The stored content differs from what was sent (a filter changed it). Read it back with gutenberg_get_blocks.'; } Util::purge_post( $id ); $p = get_post( $id ); return array_merge( Util::summary( $p ), array( 'message' => $message, 'blocks' => count( array_filter( parse_blocks( $stored ), static function ( $b ) { return null !== $b['blockName']; } ) ), 'warnings' => $warnings, ) ); } /** * Remove custom CSS/JS attributes (customCSS, kadenceBlockCSS, Spectra * custom_css...) for users without unfiltered_html. * * @param array $attrs Block attributes. * @return array */ private static function strip_code_attrs( array $attrs ): array { if ( current_user_can( 'unfiltered_html' ) ) { return $attrs; } foreach ( $attrs as $k => $v ) { if ( preg_match( '/(custom_?(css|js)|^kadenceBlockCSS|^(additional|extra)_?css)$/i', (string) $k ) ) { unset( $attrs[ $k ] ); } elseif ( is_array( $v ) ) { $attrs[ $k ] = self::strip_code_attrs( $v ); } } return $attrs; } /** * Strip code attributes from parsed blocks, recursively. * * @param array> $blocks Parsed blocks. * @return array> */ private static function strip_code_blocks( array $blocks ): array { if ( current_user_can( 'unfiltered_html' ) ) { return $blocks; } foreach ( $blocks as $i => $b ) { if ( ! empty( $b['attrs'] ) && is_array( $b['attrs'] ) ) { $blocks[ $i ]['attrs'] = self::strip_code_attrs( $b['attrs'] ); } if ( ! empty( $b['innerBlocks'] ) && is_array( $b['innerBlocks'] ) ) { $blocks[ $i ]['innerBlocks'] = self::strip_code_blocks( $b['innerBlocks'] ); } } return $blocks; } /** * Validate a page template slug against the theme's templates. * * @param string $tpl Template slug. * @param string $post_type Post type. * @return true|\WP_Error */ private static function check_template( string $tpl, string $post_type ) { $known = array_merge( array( 'default', '' ), array_keys( (array) wp_get_theme()->get_page_templates( null, $post_type ) ) ); if ( defined( 'ELEMENTOR_VERSION' ) ) { $known = array_merge( $known, array( 'elementor_canvas', 'elementor_header_footer', 'elementor_theme' ) ); } if ( function_exists( 'get_block_templates' ) && function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) { foreach ( get_block_templates( array( 'post_type' => $post_type ) ) as $bt ) { $known[] = (string) $bt->slug; } } if ( ! in_array( $tpl, $known, true ) ) { return new \WP_Error( 'invalid_template', 'Unknown page template "' . sanitize_text_field( $tpl ) . '". Use one of: ' . implode( ', ', array_unique( array_filter( $known ) ) ) . '.' ); } return true; } /** * Check markup: unknown block types and unbalanced block comments. * * @param string $content Markup. * @return string[] Warnings. */ public static function validate( string $content ): array { $warnings = array(); $reg = \WP_Block_Type_Registry::get_instance(); $unknown = array(); $walk = static function ( array $blocks ) use ( &$walk, &$unknown, $reg ) { foreach ( $blocks as $b ) { if ( $b['blockName'] && ! $reg->is_registered( $b['blockName'] ) ) { $unknown[ $b['blockName'] ] = true; } $walk( $b['innerBlocks'] ); } }; $walk( parse_blocks( $content ) ); if ( $unknown ) { $warnings[] = 'Block types not registered on this site (they will show as "unsupported" in the editor): ' . implode( ', ', array_keys( $unknown ) ); } // Count openers and closers in one pass; self-closing blocks (/-->) count as neither. preg_match_all( '//s', $content, $tags, PREG_SET_ORDER ); $open = 0; $close = 0; foreach ( $tags as $tag ) { if ( ! empty( $tag[1] ) ) { ++$close; } elseif ( empty( $tag[2] ) ) { ++$open; } } if ( $open !== $close ) { $warnings[] = "Unbalanced block comments: $open opening vs $close closing. Check the markup."; } return $warnings; } }