find_matching_brace( $css, $brace_pos ); $inner_content = substr( $css, $brace_pos + 1, $closing_brace - $brace_pos - 1 ); // Don't nest keyframes content if ( str_contains( $rule, '@keyframes' ) ) { $output[] = "\n" . $rule . ' {' . $inner_content . '}' . "\n"; } else { $output[] = "\n" . $rule . ' {'; $output[] = $this->nest( $inner_content, $class_name ); $output[] = '}' . "\n"; } $i = $closing_brace + 1; $buffer = ''; continue; }//end if if ( '{' === $char ) { $selector = trim( $buffer ); $closing_brace = $this->find_matching_brace( $css, $i ); $declarations = substr( $css, $i + 1, $closing_brace - $i - 1 ); // Preserve indentation and formatting of declarations $declarations = $this->preserve_declaration_formatting( $declarations ); if ( '' !== $selector && '' !== trim( $declarations ) ) { $prefixed_selectors = $this->prefix_selectors( $selector, $class_name ); if ( $prefixed_selectors ) { $output[] = "\n" . implode( ',' . "\n", $prefixed_selectors ) . ' {' . $declarations . '}' . "\n"; } } $i = $closing_brace + 1; $buffer = ''; continue; }//end if $buffer .= $char; ++$i; }//end while return implode( '', $output ); } /** * Unnest the CSS. * This function unnests the CSS by removing the class name prefix from the selectors. * * @param string $css * @param string $class_name * * @return string */ public function unnest( $css, $class_name ) { // Remove CSS comments but preserve newlines $css = preg_replace( '/\/\*.*?\*\//s', '', $css ); $output = array(); $css = trim( $css ); $length = strlen( $css ); $i = 0; $buffer = ''; $prefix = '.' . $class_name . ' '; $prefix_length = strlen( $prefix ); while ( $i < $length ) { $char = $css[ $i ]; if ( '@' === $char ) { $brace_pos = strpos( $css, '{', $i ); if ( false === $brace_pos ) { $buffer .= $char; ++$i; continue; } $rule = substr( $css, $i, $brace_pos - $i ); $closing_brace = $this->find_matching_brace( $css, $brace_pos ); $inner_content = substr( $css, $brace_pos + 1, $closing_brace - $brace_pos - 1 ); $output[] = "\n" . $rule . ' {'; $output[] = $this->unnest( $inner_content, $class_name ); $output[] = '}' . "\n"; $i = $closing_brace + 1; $buffer = ''; continue; }//end if if ( '{' === $char ) { $selector = trim( $buffer ); $closing_brace = $this->find_matching_brace( $css, $i ); $declarations = substr( $css, $i + 1, $closing_brace - $i - 1 ); // Preserve indentation and formatting of declarations $declarations = $this->preserve_declaration_formatting( $declarations ); if ( '' !== $selector && '' !== trim( $declarations ) ) { $unprefixed_selectors = $this->unprefix_selectors( $selector, $class_name, $prefix, $prefix_length ); if ( $unprefixed_selectors ) { $output[] = "\n" . implode( ',' . "\n", $unprefixed_selectors ) . ' {' . $declarations . '}' . "\n"; } } $i = $closing_brace + 1; $buffer = ''; continue; }//end if $buffer .= $char; ++$i; }//end while return implode( '', $output ); } /** * Preserve declaration formatting with proper indentation. * * @param string $declarations * * @return string */ private function preserve_declaration_formatting( $declarations ) { // Trim the entire block but keep internal structure $declarations = trim( $declarations ); if ( '' === $declarations ) { return ''; } // Check if declarations are already on multiple lines if ( str_contains( $declarations, "\n" ) ) { // Already formatted - preserve it $lines = explode( "\n", $declarations ); $formatted_lines = array(); foreach ( $lines as $line ) { $trimmed = trim( $line ); if ( '' !== $trimmed ) { $formatted_lines[] = "\n\t" . $trimmed; } } return implode( '', $formatted_lines ) . "\n"; } // Single line - add minimal formatting return ' ' . $declarations . ' '; } /** * Build the list of prefixed selectors for a given selector string. * Generates both descendant (.scope selector) and direct (selector.scope) forms. * * @param string $selector The comma-separated selector string. * @param string $class_name The scope class name. * * @return array The list of prefixed selectors. */ private function prefix_selectors( $selector, $class_name ) { $selectors = array_map( 'trim', explode( ',', $selector ) ); $prefixed_selectors = array(); foreach ( $selectors as $single_selector ) { if ( '' === $single_selector ) { continue; } $prefixed_selectors[] = '.' . $class_name . ' ' . $single_selector; $direct = $this->add_direct_scope( $single_selector, $class_name ); if ( null !== $direct ) { $prefixed_selectors[] = $direct; } } return $prefixed_selectors; } /** * Build the list of unprefixed selectors for a given selector string. * Handles both descendant (.scope selector) and direct (selector.scope) forms. * * @param string $selector The comma-separated selector string. * @param string $class_name The scope class name. * @param string $prefix The descendant prefix string (.class_name followed by space). * @param int $prefix_length The length of the descendant prefix. * * @return array The list of unprefixed selectors (deduplicated). */ private function unprefix_selectors( $selector, $class_name, $prefix, $prefix_length ) { $selectors = array_filter( array_map( 'trim', explode( ',', $selector ) ), function ( $s ) { return '' !== $s; } ); $unprefixed_selectors = array(); foreach ( $selectors as $single_selector ) { if ( str_starts_with( $single_selector, $prefix ) ) { $unprefixed_selectors[] = trim( substr( $single_selector, $prefix_length ) ); } else { $unprefixed_selectors[] = $this->remove_direct_scope( $single_selector, $class_name ) ?? $single_selector; } } return array_values( array_unique( $unprefixed_selectors ) ); } /** * Add the scope class directly to the first segment of a selector. * For example, "h2" becomes "h2.scope" and ".foo .bar" becomes ".foo.scope .bar". * * @param string $selector The CSS selector. * @param string $class_name The scope class name. * * @return string|null The direct-scoped selector, or null if not applicable. */ private function add_direct_scope( $selector, $class_name ) { $parts = preg_split( '/(\s+)/', $selector, 2, PREG_SPLIT_DELIM_CAPTURE ); $first = $parts[0]; // Don't add direct scope to selectors starting with combinators or universal selector. if ( preg_match( '/^[>+~*]/', $first ) ) { return null; } $rest = ''; if ( count( $parts ) > 1 ) { $rest = $parts[1] . $parts[2]; } // Capture all trailing pseudo-classes/pseudo-elements ( :before, :hover, etc. ) // Capture the chained pseudos wraps (.form_field:nth-child(2):focus ) $pseudo = ''; if ( preg_match( '/^(.*?)((?::{1,2}[a-zA-Z-]+(?:\([^)]*\))?)+)$/', $first, $pseudo_parts ) ) { $first = $pseudo_parts[1]; $pseudo = $pseudo_parts[2]; } return $first . '.' . $class_name . $pseudo . $rest; } /** * Remove the scope class from the first segment of a direct-scoped selector. * For example, "h2.scope" becomes "h2" and ".foo.scope .bar" becomes ".foo .bar". * * @param string $selector The CSS selector. * @param string $class_name The scope class name. * * @return string|null The unscoped selector, or null if the scope was not found. */ private function remove_direct_scope( $selector, $class_name ) { $scope_pattern = '/\.' . preg_quote( $class_name, '/' ) . '(?![a-zA-Z0-9_-])/'; $parts = preg_split( '/(\s+)/', $selector, 2, PREG_SPLIT_DELIM_CAPTURE ); if ( false === $parts || ! preg_match( $scope_pattern, $parts[0] ) ) { return null; } $parts[0] = preg_replace( $scope_pattern, '', $parts[0], 1 ); $result = trim( implode( '', $parts ) ); return '' !== $result ? $result : null; } /** * Find the matching brace in the CSS. * * @param string $css * @param int $open_pos * * @return int */ private function find_matching_brace( $css, $open_pos ) { $level = 1; $length = strlen( $css ); $in_string = false; $string_char = ''; for ( $i = $open_pos + 1; $i < $length; $i++ ) { $char = $css[ $i ]; // Handle string literals to avoid matching braces inside strings if ( ( '"' === $char || "'" === $char ) && ( 0 === $i || '\\' !== $css[ $i - 1 ] ) ) { if ( ! $in_string ) { $in_string = true; $string_char = $char; } elseif ( $char === $string_char ) { $in_string = false; } continue; } // Skip braces inside strings if ( $in_string ) { continue; } if ( '{' === $char ) { ++$level; } elseif ( '}' === $char ) { --$level; if ( 0 === $level ) { return $i; } } }//end for return $length - 1; } }//end class