'wp_block', 'core/navigation' => 'wp_navigation', ]; protected function __construct(...$args) { add_action( 'wp_enqueue_scripts', [ self::class, 'enqueue_compiled_styles' ] ); add_action( 'post_updated', [ self::class, 'delete_renamed_post_css_file' ], 10, 3 ); add_action( 'before_delete_post', [ self::class, 'delete_post_css_file' ] ); } /** * Validate, persist, and compile CSS submitted by the editor. * * @param \WP_REST_Request $request REST request. * @return \WP_REST_Response|\WP_Error */ public static function compile_post_styles_with_cleanup( $request ) { $raw_post_id = $request->get_param( 'post_id' ); if ( ! self::is_valid_post_id( $raw_post_id ) ) { return new \WP_Error( 'invalid_post_id', 'Post ID must be a positive integer.', [ 'status' => 400 ] ); } $post_id = absint( $raw_post_id ); $post = get_post( $post_id ); if ( ! $post ) { return new \WP_Error( 'post_not_found', 'The requested post does not exist.', [ 'status' => 404 ] ); } if ( 'trash' === $post->post_status || 'revision' === $post->post_type || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return new \WP_Error( 'invalid_post', 'Styles cannot be saved for this post.', [ 'status' => 400 ] ); } if ( ! is_user_logged_in() || ! self::current_user_can_edit_styles( $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'You do not have permission to edit styles for this post.', [ 'status' => 403 ] ); } $css = $request->get_param( 'allCss' ); if ( ! is_string( $css ) ) { return new \WP_Error( 'invalid_css', 'CSS content must be a string.', [ 'status' => 400 ] ); } if ( strlen( $css ) > self::MAX_CSS_BYTES ) { return new \WP_Error( 'css_too_large', 'CSS content cannot exceed 1 MiB.', [ 'status' => 413 ] ); } self::$last_save_error = ''; if ( ! self::compile_post_styles( $post_id, $css ) ) { $reason = self::$last_save_error ? self::$last_save_error : 'The save failed before a file operation was identified.'; return new \WP_Error( 'style_save_failed', 'Could not save the compiled block styles. ' . $reason, [ 'status' => 500, 'reason' => $reason ] ); } return new \WP_REST_Response( [ 'success' => true, 'message' => 'Block styles saved successfully.', ], 200 ); } /** * Persist the compiled CSS and write its named document stylesheet. * * Unchanged metadata is left untouched. File writes are skipped when the * existing file already contains the requested bytes. * * @param int $post_id Post ID. * @param string $all_css CSS received from the editor. * @return bool */ public static function compile_post_styles( $post_id, $all_css ) { $post_id = absint( $post_id ); if ( ! $post_id || ! is_string( $all_css ) || strlen( $all_css ) > self::MAX_CSS_BYTES ) { return self::fail_style_save( 'The post ID or CSS payload was invalid.' ); } $compiled_css = self::minify_css( $all_css ); $meta_exists = metadata_exists( 'post', $post_id, '_optimized_block_styles' ); $current_data = get_post_meta( $post_id, '_optimized_block_styles', true ); $current_css = is_array( $current_data ) && isset( $current_data['compiled_css'] ) && is_string( $current_data['compiled_css'] ) ? $current_data['compiled_css'] : null; if ( ! $meta_exists || null === $current_css || $current_css !== $compiled_css ) { update_post_meta( $post_id, '_optimized_block_styles', [ 'compiled_css' => $compiled_css ] ); // update_post_meta() returns false both for errors and unchanged // values, so verify the saved value instead of trusting its return. $saved_data = get_post_meta( $post_id, '_optimized_block_styles', true ); if ( ! is_array( $saved_data ) || ! isset( $saved_data['compiled_css'] ) || $saved_data['compiled_css'] !== $compiled_css ) { return self::fail_style_save( 'WordPress did not retain the compiled CSS post metadata.' ); } } if ( '' === $compiled_css ) { self::delete_post_css_file( $post_id ); return true; } return self::write_compiled_styles_to_file( $post_id, $compiled_css ); } /** Write a document stylesheet only when its bytes changed. */ private static function write_compiled_styles_to_file( $post_id, $compiled_css ) { $locations = self::get_styles_locations(); $filename = self::get_styles_filename( $post_id ); if ( ! $locations ) { return self::fail_style_save( 'WordPress did not provide a usable uploads directory.' ); } if ( ! $filename ) { return self::fail_style_save( 'Could not build a stylesheet filename for this post.' ); } $file_path = trailingslashit( $locations['basedir'] ) . $filename; return self::write_css_file( $file_path, $compiled_css ); } /** Remove the named stylesheet when its document is permanently deleted. */ public static function delete_post_css_file( $post_id ) { $locations = self::get_styles_locations(); $filename = self::get_styles_filename( $post_id ); if ( ! $locations || ! $filename ) { return; } $file_path = trailingslashit( $locations['basedir'] ) . $filename; if ( is_file( $file_path ) ) { wp_delete_file( $file_path ); } } /** Remove the previous filename when a document's type or slug changes. */ public static function delete_renamed_post_css_file( $post_id, $post_after, $post_before ) { if ( ! ( $post_after instanceof \WP_Post ) || ! ( $post_before instanceof \WP_Post ) ) { return; } $old_filename = self::get_styles_filename_for_post( $post_before ); $new_filename = self::get_styles_filename_for_post( $post_after ); if ( ! $old_filename || $old_filename === $new_filename ) { return; } $locations = self::get_styles_locations(); if ( ! $locations ) { return; } $old_file = trailingslashit( $locations['basedir'] ) . $old_filename; if ( is_file( $old_file ) ) { wp_delete_file( $old_file ); } } /** Enqueue each named stylesheet for the documents rendered on this page. */ public static function enqueue_compiled_styles() { $post_ids = []; if ( is_singular() ) { self::add_style_post_id( get_queried_object_id(), $post_ids ); } elseif ( is_home() || is_archive() || is_search() ) { global $wp_query; if ( $wp_query instanceof \WP_Query && ! empty( $wp_query->posts ) ) { foreach ( $wp_query->posts as $post ) { if ( $post instanceof \WP_Post ) { self::add_style_post_id( $post->ID, $post_ids ); } } } } self::collect_current_block_template_style_ids( $post_ids ); self::collect_referenced_post_style_ids( $post_ids ); self::enqueue_document_stylesheets( $post_ids ); } /** Add a positive, unique document ID while preserving page order. */ private static function add_style_post_id( $post_id, &$post_ids ) { $post_id = absint( $post_id ); if ( $post_id && ! in_array( $post_id, $post_ids, true ) ) { $post_ids[] = $post_id; } } /** Collect the active FSE template and its referenced document IDs. */ private static function collect_current_block_template_style_ids( &$post_ids ) { if ( ! current_theme_supports( 'block-templates' ) || ! function_exists( 'get_block_template' ) ) { return; } $template_id = $GLOBALS['_wp_current_template_id'] ?? ''; if ( ! is_string( $template_id ) || '' === $template_id ) { return; } $template = get_block_template( $template_id, 'wp_template' ); if ( ! $template ) { return; } if ( ! empty( $template->wp_id ) ) { self::add_style_post_id( $template->wp_id, $post_ids ); } $visited_entities = [ 'template:' . $template_id => true ]; if ( ! empty( $template->content ) ) { self::collect_document_references( parse_blocks( $template->content ), $post_ids, $visited_entities ); } } /** Resolve synced patterns, navigation entities, and template parts. */ private static function collect_document_references( array $blocks, &$post_ids, &$visited_entities ) { foreach ( $blocks as $block ) { $block_name = $block['blockName'] ?? ''; $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : []; if ( isset( self::REFERENCED_POST_TYPES[ $block_name ] ) && ! empty( $attributes['ref'] ) ) { $reference_id = absint( $attributes['ref'] ); $reference = get_post( $reference_id ); if ( $reference && self::REFERENCED_POST_TYPES[ $block_name ] === $reference->post_type && 'trash' !== $reference->post_status ) { self::add_style_post_id( $reference_id, $post_ids ); } } elseif ( 'core/template-part' === $block_name && ! empty( $attributes['slug'] ) ) { $slug = (string) $attributes['slug']; $theme = ! empty( $attributes['theme'] ) ? (string) $attributes['theme'] : get_stylesheet(); $part_id = $theme . '//' . $slug; $entity_key = 'part:' . $part_id; if ( empty( $visited_entities[ $entity_key ] ) ) { $visited_entities[ $entity_key ] = true; $part = function_exists( 'get_block_template' ) ? get_block_template( $part_id, 'wp_template_part' ) : null; if ( $part ) { if ( ! empty( $part->wp_id ) ) { self::add_style_post_id( $part->wp_id, $post_ids ); } if ( ! empty( $part->content ) ) { self::collect_document_references( parse_blocks( $part->content ), $post_ids, $visited_entities ); } } } } if ( ! empty( $block['innerBlocks'] ) ) { self::collect_document_references( $block['innerBlocks'], $post_ids, $visited_entities ); } } } /** Expand all saved documents to include the post records they reference. */ private static function collect_referenced_post_style_ids( &$post_ids ) { $visited_posts = []; $visited_entities = []; for ( $index = 0; $index < count( $post_ids ); $index++ ) { $post_id = absint( $post_ids[ $index ] ); if ( ! $post_id || isset( $visited_posts[ $post_id ] ) ) { continue; } $visited_posts[ $post_id ] = true; $post = get_post( $post_id ); if ( ! $post || in_array( $post->post_status, [ 'trash', 'auto-draft' ], true ) || '' === $post->post_content ) { continue; } self::collect_document_references( parse_blocks( $post->post_content ), $post_ids, $visited_entities ); } } /** Enqueue the saved CSS file for each document, with an inline fallback. */ private static function enqueue_document_stylesheets( array $post_ids ) { $locations = self::get_styles_locations(); foreach ( array_unique( array_map( 'absint', $post_ids ) ) as $post_id ) { if ( ! $post_id ) { continue; } $filename = self::get_styles_filename( $post_id ); if ( $locations && $filename ) { $file_path = trailingslashit( $locations['basedir'] ) . $filename; if ( is_file( $file_path ) ) { // Files written by older versions may still have the // 0600 mode assigned by tempnam(), which prevents the web // server from reading them as static assets. if ( self::set_css_file_permissions( $file_path ) ) { $modified = @filemtime( $file_path ); wp_enqueue_style( 'flexify-blocks-document-' . $post_id, trailingslashit( $locations['baseurl'] ) . $filename, [], false !== $modified ? (string) $modified : ( defined( 'FLBL_VERSION' ) ? FLBL_VERSION : null ) ); continue; } } } $compiled_data = get_post_meta( $post_id, '_optimized_block_styles', true ); $css = is_array( $compiled_data ) && isset( $compiled_data['compiled_css'] ) && is_string( $compiled_data['compiled_css'] ) ? $compiled_data['compiled_css'] : ''; if ( '' !== $css ) { $handle = 'flexify-blocks-document-' . $post_id . '-inline'; wp_register_style( $handle, false, [], defined( 'FLBL_VERSION' ) ? FLBL_VERSION : null ); wp_enqueue_style( $handle ); wp_add_inline_style( $handle, $css ); } } } /** Return the deterministic CSS filename for a document post. */ private static function get_styles_filename( $post_id ) { $post = get_post( absint( $post_id ) ); if ( ! $post ) { return false; } return self::get_styles_filename_for_post( $post ); } /** Build a document filename from its post type, slug, and ID. */ private static function get_styles_filename_for_post( $post ) { if ( ! ( $post instanceof \WP_Post ) ) { return false; } $post_type = sanitize_key( $post->post_type ); $slug = (string) $post->post_name; if ( in_array( $post_type, [ 'wp_template', 'wp_template_part' ], true ) ) { $namespace_separator = strrpos( $slug, '//' ); if ( false !== $namespace_separator ) { $slug = substr( $slug, $namespace_separator + 2 ); } } $slug = sanitize_file_name( $slug ); if ( '' === $slug ) { $slug = sanitize_file_name( sanitize_title( $post->post_title ) ); } if ( '' === $slug ) { $slug = 'document'; } return $post_type . '-' . $slug . '-' . absint( $post->ID ) . '.css'; } /** Return the plugin CSS directory in uploads, or false on configuration errors. */ private static function get_styles_locations() { $upload_dir = wp_upload_dir(); if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['basedir'] ) || empty( $upload_dir['baseurl'] ) ) { return false; } // Some hosts retain an http:// upload_url_path after the site has // moved to HTTPS. Browsers block that stylesheet as mixed content, // even though the file exists and WordPress enqueued its URL. $home_scheme = wp_parse_url( home_url(), PHP_URL_SCHEME ); $url_scheme = is_ssl() || 'https' === strtolower( (string) $home_scheme ) ? 'https' : 'http'; return [ 'basedir' => trailingslashit( $upload_dir['basedir'] ) . 'flexify-blocks', 'baseurl' => set_url_scheme( trailingslashit( $upload_dir['baseurl'] ) . 'flexify-blocks', $url_scheme ), ]; } /** Write CSS through WordPress's filesystem API, skipping unchanged content. */ private static function write_css_file( $file_path, $css ) { if ( ! is_string( $css ) ) { return false; } $directory = dirname( $file_path ); if ( ! is_dir( $directory ) && ! wp_mkdir_p( $directory ) ) { return self::fail_style_save( 'Could not create the Flexify Blocks CSS directory in uploads.' ); } if ( is_file( $file_path ) ) { $existing_css = @file_get_contents( $file_path ); if ( false !== $existing_css && $existing_css === $css ) { self::set_css_file_permissions( $file_path ); return true; } } // Use tempnam() here because wp_tempnam() lives in an admin include // that is not guaranteed to be loaded for front-end REST requests. $temporary_file = @tempnam( $directory, 'flexify-' ); $temporary_directory = $temporary_file ? untrailingslashit( wp_normalize_path( dirname( $temporary_file ) ) ) : ''; $expected_directory = untrailingslashit( wp_normalize_path( $directory ) ); $same_directory = DIRECTORY_SEPARATOR === '\\' ? 0 === strcasecmp( $temporary_directory, $expected_directory ) : $temporary_directory === $expected_directory; if ( ! $temporary_file || ! $same_directory ) { if ( $temporary_file && is_file( $temporary_file ) ) { wp_delete_file( $temporary_file ); } return self::fail_style_save( 'Could not create a temporary CSS file inside the uploads directory.' ); } $bytes_written = @file_put_contents( $temporary_file, $css, LOCK_EX ); if ( false === $bytes_written || $bytes_written !== strlen( $css ) ) { wp_delete_file( $temporary_file ); return self::fail_style_save( 'Could not write the complete CSS contents to the temporary file.' ); } // Permission changes are best-effort here. Some filesystems do not support Unix // modes; the frontend will use the saved metadata inline if it cannot // make the generated file readable by the web server. self::set_css_file_permissions( $temporary_file ); $filesystem = self::get_wp_filesystem( $directory ); if ( ! $filesystem ) { wp_delete_file( $temporary_file ); return self::fail_style_save( 'Could not initialize the WordPress filesystem for the CSS file.' ); } // WP_Filesystem::move() handles overwrite and its platform-specific copy fallback. $file_moved = $filesystem->move( $temporary_file, $file_path, true ); if ( ! $file_moved ) { wp_delete_file( $temporary_file ); return self::fail_style_save( 'Could not move the completed CSS file into its final uploads path.' ); } return true; } /** Record a safe diagnostic for the REST response and fail the save. */ private static function fail_style_save( $reason ) { self::$last_save_error = (string) $reason; return false; } /** Make generated stylesheets readable by the web server. */ private static function set_css_file_permissions( $file_path ) { $permissions = defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644; $filesystem = self::get_wp_filesystem( dirname( $file_path ) ); if ( ! $filesystem ) { return DIRECTORY_SEPARATOR === '\\'; } $changed = $filesystem->chmod( $file_path, $permissions ); // Windows uses ACLs for access control; filesystem chmod only maps partially // to the read-only flag there, so a false result is not actionable. return DIRECTORY_SEPARATOR === '\\' || $changed; } /** Initialize WordPress's filesystem API for this request when needed. */ private static function get_wp_filesystem( $context = false ) { if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } global $wp_filesystem; if ( ! ( $wp_filesystem instanceof \WP_Filesystem_Base ) && ! \WP_Filesystem( false, $context ) ) { return false; } return $wp_filesystem instanceof \WP_Filesystem_Base ? $wp_filesystem : false; } /** Check whether the current user can edit a post or an FSE entity. */ private static function current_user_can_edit_styles( $post_id ) { $post_type = get_post_type( $post_id ); if ( in_array( $post_type, [ 'wp_template', 'wp_template_part' ], true ) ) { return current_user_can( 'edit_theme_options' ); } return current_user_can( 'edit_post', $post_id ); } /** Validate REST IDs without accepting floats, exponents, or arrays. */ private static function is_valid_post_id( $post_id ) { if ( is_int( $post_id ) ) { return $post_id > 0; } return is_string( $post_id ) && 1 === preg_match( '/^[1-9][0-9]*$/D', $post_id ); } /** * Compact generated CSS. * * @param string $css CSS text. * @return string */ private static function minify_css( $css ) { $css = preg_replace( '!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css ); $css = str_replace( [ "\r\n", "\r", "\n", "\t" ], '', $css ); $css = preg_replace( '/\s+/', ' ', $css ); $css = str_replace( [ ' {', '{ ', ' }', '} ', '; ', ' ;', ', ', ' ,' ], [ '{', '{', '}', '}', ';', ';', ',', ',' ], $css ); return trim( $css ); } }