*/ private array $styles = array(); /** * Absolute path to the CSS upload directory, or null until resolved. * * @since 3.0.0 * @var string|null */ private ?string $upload_dir = null; /** * Public URL to the CSS upload directory, or null until resolved. * * @since 3.0.0 * @var string|null */ private ?string $upload_url = null; /** * Constructor. * * @since 3.0.0 */ public function __construct() { add_action( 'wp', array( $this, 'register_render_hooks' ) ); } /** * Register the collection and output hooks for a front end request. * * Deferred to `wp` so the active theme is fully resolved before * `wp_is_block_theme()` decides where the stylesheet is printed, and so * admin, REST and cron requests never collect styles they cannot use. * * @since 3.0.0 * * @return void */ public function register_render_hooks(): void { if ( is_admin() || is_feed() ) { return; } add_filter( 'render_block', array( $this, 'collect_block_styles' ), 10, 2 ); if ( wp_is_block_theme() ) { add_action( 'wp_enqueue_scripts', array( $this, 'generate_and_enqueue_combined_css' ) ); } else { add_action( 'wp_footer', array( $this, 'generate_and_enqueue_combined_css' ) ); } } /** * Collect CSS styles from a rendered block. * * Inspects the block's attributes for inline styles and stores * them for later combination into a single stylesheet. * * @since 3.0.0 * * @param string $block_content The rendered block HTML. * @param array $block The block data array. * @return string The unmodified block content. */ public function collect_block_styles( string $block_content, array $block ): string { if ( isset( $block['blockName'] ) && str_starts_with( (string) $block['blockName'], 'gutsliders/' ) ) { do_action( 'gutsliders_render_block', $block ); if ( isset( $block['attrs']['blockStyle'] ) ) { $style = $block['attrs']['blockStyle']; if ( is_array( $style ) && ! empty( $style ) ) { $style = implode( ' ', $style ); } if ( is_string( $style ) && '' !== $style ) { $style = $this->sanitize_css( $style ); if ( '' !== $style ) { $this->styles[ md5( $style ) ] = $style; } } } } return $block_content; } /** * Generate and output the combined CSS for the current page. * * @since 3.0.0 * * @return void */ public function generate_and_enqueue_combined_css(): void { if ( empty( $this->styles ) ) { return; } $minified_css = $this->minify_css( implode( "\n", $this->styles ) ); if ( '' === $minified_css ) { return; } /* * Inline output doubles as the fallback: if the stylesheet cannot be * written or enqueued for any reason, print it rather than serving * the page unstyled. */ if ( 'inline' === $this->get_delivery_mode() || ! $this->enqueue_css_file( $minified_css ) ) { $this->print_inline_css( $minified_css ); } } /** * Write the combined CSS to the uploads directory and enqueue it. * * @since 3.0.0 * * @param string $css The minified CSS string. * @return bool True when the stylesheet was enqueued. */ private function enqueue_css_file( string $css ): bool { $filesystem = $this->filesystem(); if ( null === $filesystem || ! $this->prepare_upload_dir( $filesystem ) ) { return false; } /* * Use the queried object rather than get_the_ID(): by the time styles * are output the loop has finished, so the global post still points at * the last post rendered and an archive would otherwise write its * combined CSS over that post's own stylesheet. Anything that is not a * single post gets a content derived name instead, which keeps two * unrelated archives from sharing a file. */ $queried_id = is_singular() ? get_queried_object_id() : 0; $file_suffix = $queried_id ? (string) $queried_id : 'archive-' . md5( $css ); $file_name = 'gutslider-styles-' . $file_suffix . '.min.css'; $file_path = $this->upload_dir . $file_name; $existing = $filesystem->exists( $file_path ) ? $filesystem->get_contents( $file_path ) : false; if ( $existing !== $css && ! $this->write_file( $filesystem, $file_path, $css ) ) { return false; } $version = file_exists( $file_path ) ? filemtime( $file_path ) : false; if ( false === $version ) { return false; } wp_enqueue_style( 'gutslider-combined-styles', $this->upload_url . $file_name, array(), (string) $version ); return true; } /** * Write a file, replacing any existing one atomically. * * The contents go to a temporary file first and are then renamed over the * target, so a concurrent request never reads a half written stylesheet. * * @since 3.0.0 * * @param \WP_Filesystem_Base $filesystem The filesystem abstraction. * @param string $path Absolute destination path. * @param string $contents File contents. * @return bool True on success. */ private function write_file( \WP_Filesystem_Base $filesystem, string $path, string $contents ): bool { $temp_path = $path . '.' . wp_generate_password( 8, false ) . '.tmp'; if ( ! $filesystem->put_contents( $temp_path, $contents, FS_CHMOD_FILE ) ) { $filesystem->delete( $temp_path ); return false; } if ( ! $filesystem->move( $temp_path, $path, true ) ) { $filesystem->delete( $temp_path ); return false; } return true; } /** * Resolve the upload paths and make sure the directory exists. * * @since 3.0.0 * * @param \WP_Filesystem_Base $filesystem The filesystem abstraction. * @return bool True when the directory is available. */ private function prepare_upload_dir( \WP_Filesystem_Base $filesystem ): bool { if ( null === $this->upload_dir ) { $upload_dir = wp_upload_dir(); if ( ! empty( $upload_dir['error'] ) ) { return false; } $this->upload_dir = trailingslashit( $upload_dir['basedir'] ) . 'gutslider-styles/'; $this->upload_url = trailingslashit( $upload_dir['baseurl'] ) . 'gutslider-styles/'; } if ( ! is_dir( $this->upload_dir ) && ! wp_mkdir_p( $this->upload_dir ) ) { return false; } // Keep the directory from being listed on servers with indexes enabled. $index_file = $this->upload_dir . 'index.php'; if ( ! $filesystem->exists( $index_file ) ) { $filesystem->put_contents( $index_file, "` * collapses into `` once the inner match is taken out. Each pass * shortens the string, so this always terminates. * * @since 3.0.0 * * @param string $css The raw CSS string. * @return string The sanitized CSS string. */ private function sanitize_css( string $css ): string { do { $previous = $css; $css = (string) preg_replace( '#])\s*/', '$1', $css ); // Drop the final semicolon of each rule. $css = str_replace( ';}', '}', $css ); $css = trim( $css ); if ( ! empty( $strings ) ) { $css = (string) preg_replace_callback( "/\0gs(\d+)\0/", static function ( array $match ) use ( $strings ): string { return $strings[ (int) $match[1] ]; }, $css ); } return $css; } }