diff --git a/src/wp-includes/block-template-utils.php b/src/wp-includes/block-template-utils.php index 2c2fd66d7c332..b0086f5d301fc 100644 --- a/src/wp-includes/block-template-utils.php +++ b/src/wp-includes/block-template-utils.php @@ -454,6 +454,39 @@ function _inject_theme_attribute_in_block_template_content( $template_content ) return $template_content; } +/** + * Parses a block template and removes the theme attribute from each template part. + * + * @access private + * @since 5.9.0 + * + * @param string $template_content Serialized block template content. + * @return string Updated block template content. + */ +function _remove_theme_attribute_in_block_template_content( $template_content ) { + $has_updated_content = false; + $new_content = ''; + $template_blocks = parse_blocks( $template_content ); + + $blocks = _flatten_blocks( $template_blocks ); + foreach ( $blocks as $key => $block ) { + if ( 'core/template-part' === $block['blockName'] && isset( $block['attrs']['theme'] ) ) { + unset( $blocks[ $key ]['attrs']['theme'] ); + $has_updated_content = true; + } + } + + if ( ! $has_updated_content ) { + return $template_content; + } + + foreach ( $template_blocks as $block ) { + $new_content .= serialize_block( $block ); + } + + return $new_content; +} + /** * Build a unified template object based on a theme file. * @@ -863,3 +896,55 @@ function block_header_area() { function block_footer_area() { block_template_part( 'footer' ); } + +/** + * Creates an export of the current templates and + * template parts from the site editor at the + * specified path in a ZIP file. + * + * @since 5.9.0 + * + * @return WP_Error|string Path of the ZIP file or error on failure. + */ +function wp_generate_block_templates_export_file() { + if ( ! class_exists( 'ZipArchive' ) ) { + return new WP_Error( __( 'Zip Export not supported.' ) ); + } + + $obscura = wp_generate_password( 12, false, false ); + $filename = get_temp_dir() . 'edit-site-export-' . $obscura . '.zip'; + + $zip = new ZipArchive(); + if ( true !== $zip->open( $filename, ZipArchive::CREATE ) ) { + return new WP_Error( __( 'Unable to open export file (archive) for writing.' ) ); + } + + $zip->addEmptyDir( 'theme' ); + $zip->addEmptyDir( 'theme/templates' ); + $zip->addEmptyDir( 'theme/parts' ); + + // Load templates into the zip file. + $templates = get_block_templates(); + foreach ( $templates as $template ) { + $template->content = _remove_theme_attribute_in_block_template_content( $template->content ); + + $zip->addFromString( + 'theme/templates/' . $template->slug . '.html', + $template->content + ); + } + + // Load template parts into the zip file. + $template_parts = get_block_templates( array(), 'wp_template_part' ); + foreach ( $template_parts as $template_part ) { + $zip->addFromString( + 'theme/parts/' . $template_part->slug . '.html', + $template_part->content + ); + } + + // Save changes to the zip file. + $zip->close(); + + return $filename; +} diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 25f23fae64396..9617c81c91d99 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -349,6 +349,10 @@ function create_initial_rest_routes() { // Menu Locations. $controller = new WP_REST_Menu_Locations_Controller(); $controller->register_routes(); + + // Site Editor Export. + $controller = new WP_REST_Edit_Site_Export_Controller(); + $controller->register_routes(); } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php new file mode 100644 index 0000000000000..1bd40cd637c14 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php @@ -0,0 +1,93 @@ +namespace = 'wp-block-editor/v1'; + $this->rest_base = 'export'; + } + + /** + * Registers the site export route. + * + * @since 5.9.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'export' ), + 'permission_callback' => array( $this, 'permissions_check' ), + ), + ) + ); + } + + /** + * Checks whether a given request has permission to export. + * + * @since 5.9.0 + * + * @return WP_Error|true True if the request has access, or WP_Error object. + */ + public function permissions_check() { + if ( ! current_user_can( 'edit_theme_options' ) ) { + new WP_Error( + 'rest_cannot_view_url_details', + __( 'Sorry, you are not allowed to export templates and template parts.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Output a ZIP file with an export of the current templates + * and template parts from the site editor, and close the connection. + * + * @since 5.9.0 + * + * @return WP_Error|void + */ + public function export() { + // Generate the export file. + $filename = wp_generate_block_templates_export_file(); + + if ( is_wp_error( $filename ) ) { + $filename->add_data( array( 'status' => 500 ) ); + + return $filename; + } + + header( 'Content-Type: application/zip' ); + header( 'Content-Disposition: attachment; filename=edit-site-export.zip' ); + header( 'Content-Length: ' . filesize( $filename ) ); + flush(); + readfile( $filename ); + unlink( $filename ); + exit; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 3b0e179baf149..85b2f2146a680 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -275,6 +275,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-themes-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-plugins-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-directory-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-edit-site-export-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-pattern-directory-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-application-passwords-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-site-health-controller.php'; diff --git a/tests/phpunit/tests/block-template-utils.php b/tests/phpunit/tests/block-template-utils.php index 930735c72fd6a..fc382862a89c4 100644 --- a/tests/phpunit/tests/block-template-utils.php +++ b/tests/phpunit/tests/block-template-utils.php @@ -189,6 +189,38 @@ function test_inject_theme_attribute_in_block_template_content() { $this->assertSame( $content_with_no_template_part, $template_content ); } + /** + * @ticket 54448 + * + * @dataProvider data_remove_theme_attribute_in_block_template_content + */ + function test_remove_theme_attribute_in_block_template_content( $template_content, $expected ) { + $this->assertEquals( $expected, _remove_theme_attribute_in_block_template_content( $template_content ) ); + } + + function data_remove_theme_attribute_in_block_template_content() { + return array( + array( + '', + '', + ), + array( + '', + '', + ), + // Does not modify content when there is no existing theme attribute. + array( + '', + '', + ), + // Does not remove theme when there is no template part. + array( + '', + '', + ), + ); + } + /** * Should retrieve the template from the theme files. */ @@ -311,4 +343,37 @@ function test_flatten_blocks() { $expected = array( $blocks[0] ); $this->assertSame( $expected, $actual ); } + + /** + * Should generate block templates export file. + * + * @ticket 54448 + */ + function test_wp_generate_block_templates_export_file() { + $filename = wp_generate_block_templates_export_file(); + $this->assertFileExists( $filename, 'zip file is created at the specified path' ); + $this->assertTrue( filesize( $filename ) > 0, 'zip file is larger than 0 bytes' ); + + // Open ZIP file and make sure the directories exist. + $zip = new ZipArchive(); + $zip->open( $filename ); + $has_theme_dir = $zip->locateName( 'theme/' ) !== false; + $has_block_templates_dir = $zip->locateName( 'theme/templates/' ) !== false; + $has_block_template_parts_dir = $zip->locateName( 'theme/parts/' ) !== false; + $this->assertTrue( $has_theme_dir, 'theme directory exists' ); + $this->assertTrue( $has_block_templates_dir, 'theme/templates directory exists' ); + $this->assertTrue( $has_block_template_parts_dir, 'theme/parts directory exists' ); + + // ZIP file contains at least one HTML file. + $has_html_files = false; + $num_files = $zip->numFiles; + for ( $i = 0; $i < $num_files; $i++ ) { + $filename = $zip->getNameIndex( $i ); + if ( '.html' === substr( $filename, -5 ) ) { + $has_html_files = true; + break; + } + } + $this->assertTrue( $has_html_files, 'contains at least one html file' ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 346645b7c568b..d6e11af97316f 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -10642,6 +10642,27 @@ mockedApiResponse.Schema = { } } ] + }, + "/wp-block-editor/v1/export": { + "namespace": "wp-block-editor/v1", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": [] + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp-block-editor/v1/export" + } + ] + } } }, "site_logo": 0,