388 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			388 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| namespace Sphere\Debloat;
 | |
| use Sphere\Debloat\RemoveCss\Sanitizer;
 | |
| use Sphere\Debloat\OptimizeCss\Stylesheet;
 | |
| 
 | |
| /**
 | |
|  * Process stylesheets to debloat and optimize CSS.
 | |
|  * 
 | |
|  * @author  asadkn
 | |
|  * @since   1.0.0
 | |
|  */
 | |
| class RemoveCss
 | |
| {
 | |
| 	/**
 | |
| 	 * @var \DOMDocument
 | |
| 	 */
 | |
| 	public $dom;
 | |
| 	public $html;
 | |
| 
 | |
| 	/**
 | |
| 	 * @var array
 | |
| 	 */
 | |
| 	public $used_markup = [];
 | |
| 
 | |
| 	/**
 | |
| 	 * @var array
 | |
| 	 */
 | |
| 	protected $stylesheets;
 | |
| 
 | |
| 	protected $include_sheets = [];
 | |
| 	protected $exclude_sheets = [];
 | |
| 
 | |
| 	/**
 | |
| 	 * Undocumented function
 | |
| 	 *
 | |
| 	 * @param Stylesheet[] $stylesheets
 | |
| 	 * @param \DOMDocument $dom
 | |
| 	 * @param string $raw_html
 | |
| 	 */
 | |
| 	public function __construct($stylesheets, \DOMDocument $dom, string $raw_html)
 | |
| 	{
 | |
| 		$this->stylesheets = $stylesheets;
 | |
| 		$this->dom         = $dom;
 | |
| 		$this->html        = $raw_html;
 | |
| 	}
 | |
| 
 | |
| 	public function process()
 | |
| 	{		
 | |
| 		// Collect all the classes, ids, tags used in DOM.
 | |
| 		$this->find_used_selectors();
 | |
| 
 | |
| 		// Figure out valid sheets.
 | |
| 		$this->setup_valid_sheets();
 | |
| 
 | |
| 		/**
 | |
| 		 * Process and replace stylesheets with CSS cleaned.
 | |
| 		 */
 | |
| 		do_action('debloat/remove_css_begin', $this);
 | |
| 		$allow_selectors = $this->get_allowed_selectors();
 | |
| 
 | |
| 		foreach ($this->stylesheets as $sheet) {
 | |
| 			if (!$this->should_process_stylesheet($sheet)) {
 | |
| 				// Util\debug_log('Skipping: ' . print_r($sheet, true));
 | |
| 				continue;
 | |
| 			}
 | |
| 
 | |
| 			// Perhaps not a local file or unreadable.
 | |
| 			$file_data = $this->process_file_by_url($sheet->url);
 | |
| 			if (!$file_data) {
 | |
| 				continue;
 | |
| 			}
 | |
| 
 | |
| 			$sheet->content = $file_data['content'];
 | |
| 			$sheet->file    = $file_data['file'];
 | |
| 
 | |
| 			// Parsed sheet will be cached instead of being parsed by Sabberworm again.
 | |
| 			$this->setup_sheet_cache($sheet);
 | |
| 
 | |
| 			/**
 | |
| 			 * Fire up sanitizer to process and remove unused CSS.
 | |
| 			 */
 | |
| 			$sanitizer = new Sanitizer($sheet, $this->used_markup, $allow_selectors);
 | |
| 			$sanitized_css = $sanitizer->sanitize();
 | |
| 
 | |
| 			// Store sizes for debug info.
 | |
| 			$sheet->original_size = strlen($sheet->content);
 | |
| 			$sheet->new_size =  $sanitized_css ? strlen($sanitized_css) : $sheet->original_size;
 | |
| 
 | |
| 			if ($sanitized_css) {
 | |
| 
 | |
| 				// Pre-render as we'll have to add a delayed css tag as well, if enabled.
 | |
| 				$sheet->content   = $sanitized_css;
 | |
| 				$sheet->render_id = 'debloat-' . $sheet->id;
 | |
| 				$sheet->set_render('inline');
 | |
| 				$replacement = $sheet->render();
 | |
| 
 | |
| 				// Add tags for delayed CSS files.
 | |
| 				if (Plugin::delay_load()->should_delay_css()) {
 | |
| 
 | |
| 					$sheet->delay_type = Plugin::options()->delay_css_type;
 | |
| 					$sheet->set_render('delay');
 | |
| 					$sheet->has_delay = true;
 | |
| 
 | |
| 					// Add the delay load CSS tag in addition to inlined sanitized CSS above.
 | |
| 					$replacement .= $sheet->render();
 | |
| 				}
 | |
| 
 | |
| 				$sheet->set_render('remove_css', $replacement);
 | |
| 
 | |
| 				// Save in parsed css cache if not already saved.
 | |
| 				$this->save_sheet_cache($sheet);
 | |
| 			}
 | |
| 
 | |
| 			// Free up memory.
 | |
| 			$sheet->content = '';
 | |
| 			$sheet->parsed_data = '';
 | |
| 		}
 | |
| 
 | |
| 		// $this->stylesheets = array_map(function($sheet) {
 | |
| 		// 	if (isset($sheet->original_size)) {
 | |
| 		// 		$sheet->saved = $sheet->original_size - $sheet->new_size;
 | |
| 		// 	}
 | |
| 		// 	return $sheet;
 | |
| 		// }, $this->stylesheets);
 | |
| 
 | |
| 		$total = array_reduce($this->stylesheets, function($acc, $item) {
 | |
| 			if (!empty($item->original_size)) {
 | |
| 				$acc += ($item->original_size - $item->new_size);
 | |
| 			}
 | |
| 			return $acc;
 | |
| 		}, 0);
 | |
| 
 | |
| 		$this->html .= "\n<!-- Debloat Remove CSS Saved: {$total} bytes. -->";
 | |
| 		
 | |
| 		return $this->html;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Add parsed data cache to stylesheet object. Will be used by save_sheet_cache later.
 | |
| 	 *
 | |
| 	 * @param Stylesheet $sheet
 | |
| 	 * @return void
 | |
| 	 */
 | |
| 	public function setup_sheet_cache(Stylesheet $sheet)
 | |
| 	{
 | |
| 		if (!isset($sheet->file)) {
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		$cache = get_transient($this->get_transient_id($sheet));
 | |
| 		if ($cache && $cache['mtime'] < Plugin::file_system()->mtime($sheet->file)) {
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		if ($cache && !empty($cache['data'])) {
 | |
| 			$sheet->parsed_data = $cache['data'];
 | |
| 			$sheet->has_cache = true;
 | |
| 			return;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	protected function get_transient_id($sheet)
 | |
| 	{
 | |
| 		return substr('debloat_sheet_cache_' . $sheet->id, 0, 190);
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Cache the parsed data.
 | |
| 	 * 
 | |
| 	 * Note: This doesn't cache whole CSS as that would vary based on found selectors.
 | |
| 	 *
 | |
| 	 * @param Stylesheet $sheet
 | |
| 	 * @return void
 | |
| 	 */
 | |
| 	public function save_sheet_cache(Stylesheet $sheet)
 | |
| 	{
 | |
| 		if ($sheet->has_cache) {
 | |
| 			return;
 | |
| 		}
 | |
| 		
 | |
| 		$cache_data = [
 | |
| 			'data'  => $sheet->parsed_data,
 | |
| 			'mtime' => Plugin::file_system()->mtime($sheet->file)
 | |
| 		];
 | |
| 
 | |
| 		// With expiry; won't be auto-loaded.
 | |
| 		set_transient($this->get_transient_id($sheet), $cache_data, MONTH_IN_SECONDS);
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Setup includes and excludes based on options.
 | |
| 	 *
 | |
| 	 * @return void
 | |
| 	 */
 | |
| 	public function setup_valid_sheets()
 | |
| 	{
 | |
| 		$default_excludes = [
 | |
| 			'wp-includes/css/dashicons.css',
 | |
| 			'admin-bar.css',
 | |
| 			'wp-mediaelement'
 | |
| 		];
 | |
| 		
 | |
| 		$excludes = array_merge(
 | |
| 			$default_excludes, 
 | |
| 			Util\option_to_array(Plugin::options()->remove_css_excludes)
 | |
| 		);
 | |
| 
 | |
| 		$this->exclude_sheets = apply_filters('debloat/remove_css_excludes', $excludes, $this);
 | |
| 
 | |
| 		if (Plugin::options()->remove_css_all) {
 | |
| 			return;
 | |
| 		}
 | |
| 
 | |
| 		if (Plugin::options()->remove_css_theme) {
 | |
| 			$this->include_sheets[] = content_url('themes') . '/*';
 | |
| 		}
 | |
| 
 | |
| 		if (Plugin::options()->remove_css_plugins) {
 | |
| 			$this->include_sheets[] = content_url('plugins') . '/*';
 | |
| 		}
 | |
| 
 | |
| 		$this->include_sheets = array_merge(
 | |
| 			$this->include_sheets,
 | |
| 			Util\option_to_array(Plugin::options()->remove_css_includes)
 | |
| 		);
 | |
| 
 | |
| 		$this->include_sheets = apply_filters('debloat/remove_css_includes', $this->include_sheets, $this);
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Determine if stylesheet should be processed, based on exclusion and inclusion
 | |
| 	 * rules and settings.
 | |
| 	 *
 | |
| 	 * @param Stylesheet $sheet
 | |
| 	 * @return boolean
 | |
| 	 */
 | |
| 	public function should_process_stylesheet(Stylesheet $sheet)
 | |
| 	{
 | |
| 		// Handle manual excludes first.
 | |
| 		if ($this->exclude_sheets) {
 | |
| 			foreach ($this->exclude_sheets as $exclude) {
 | |
| 				if (Util\asset_match($exclude, $sheet)) {
 | |
| 					return false;
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// All stylesheets are valid.
 | |
| 		if (Plugin::options()->remove_css_all) {
 | |
| 			return true;
 | |
| 		}
 | |
| 
 | |
| 		foreach ($this->include_sheets as $include) {
 | |
| 			if (Util\asset_match($include, $sheet)) {
 | |
| 				return true;
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return false;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Get all the allowed selectors in correct data format to be used by sanitizer.
 | |
| 	 *
 | |
| 	 * @return array
 | |
| 	 */
 | |
| 	public function get_allowed_selectors()
 | |
| 	{
 | |
| 		// Allowed selectors of type 'any': simple match in selector string.
 | |
| 		$allowed_any = array_map(
 | |
| 			function($value) {
 | |
| 				if (!$value) {
 | |
| 					return '';
 | |
| 				}
 | |
| 
 | |
| 				return [
 | |
| 					'type'   => 'any',
 | |
| 					'search' => [$value]
 | |
| 				];
 | |
| 			},
 | |
| 			Util\option_to_array((string) Plugin::options()->allow_css_selectors)
 | |
| 		);
 | |
| 
 | |
| 		// Conditional selectors.
 | |
| 		$allowed_conditionals = [];
 | |
| 		$conditionals = Plugin::options()->allow_css_conditionals
 | |
| 			? (array) Plugin::options()->allow_conditionals_data
 | |
| 			: [];
 | |
| 
 | |
| 		if ($conditionals) {
 | |
| 			$allowed_conditionals = array_map(
 | |
| 				function($value) {
 | |
| 					if (!isset($value['match'])) {
 | |
| 						return '';
 | |
| 					}
 | |
| 
 | |
| 					$value['class'] = preg_replace('/^\./', '', trim($value['match']));
 | |
| 
 | |
| 					if ($value['type'] !== 'prefix' && isset($value['selectors'])) {
 | |
| 						$value['search'] = Util\option_to_array($value['selectors']);
 | |
| 					}
 | |
| 
 | |
| 					return $value;
 | |
| 				},
 | |
| 				$conditionals
 | |
| 			);
 | |
| 		}
 | |
| 
 | |
| 		$allowed = apply_filters(
 | |
| 			'debloat/allow_css_selectors', 
 | |
| 			array_filter(array_merge($allowed_any, $allowed_conditionals)),
 | |
| 			$this
 | |
| 		);
 | |
| 
 | |
| 		return $allowed;
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Find all the classes, ids, and tags used in the document.
 | |
| 	 *
 | |
| 	 * @return void
 | |
| 	 */
 | |
| 	protected function find_used_selectors()
 | |
| 	{
 | |
| 		$this->used_markup = [
 | |
| 			'tags'    => [],
 | |
| 			'classes' => [],
 | |
| 			'ids'     => [],
 | |
| 		];
 | |
| 
 | |
| 		/**
 | |
| 		 * @var DOMElement $node
 | |
| 		 */
 | |
| 		$classes = [];
 | |
| 		foreach ($this->dom->getElementsByTagName('*') as $node) {
 | |
| 			$this->used_markup['tags'][ $node->tagName ] = 1;
 | |
| 
 | |
| 			// Collect tag classes.
 | |
| 			if ($node->hasAttribute('class')) {
 | |
| 				$class = $node->getAttribute('class');
 | |
| 				$ele_classes = preg_split('/\s+/', $class);
 | |
| 				array_push($classes, ...$ele_classes);
 | |
| 			}
 | |
| 
 | |
| 			if ($node->hasAttribute('id')) {
 | |
| 				$this->used_markup['ids'][ $node->getAttribute('id') ] = 1;
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// Add the classes.
 | |
| 		$classes = array_filter(array_unique($classes));
 | |
| 		if ($classes) {
 | |
| 			$this->used_markup['classes'] = array_fill_keys($classes, 1);
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	/**
 | |
| 	 * Process a stylesheet via URL
 | |
| 	 * 
 | |
| 	 * @uses Plugin::file_system()
 | |
| 	 * @uses Plugin::file_system()->url_to_local()
 | |
| 	 * @return boolean|array With 'content' and 'file'.
 | |
| 	 */
 | |
| 	public function process_file_by_url($url)
 | |
| 	{
 | |
| 		// Try to get local path for this stylesheet
 | |
| 		$file = Plugin::file_system()->url_to_local($url);
 | |
| 		if (!$file) {
 | |
| 			return false;
 | |
| 		}
 | |
| 
 | |
| 		// We can only support .css files yet
 | |
| 		if (substr($file, -4) !== '.css') {
 | |
| 			return false;
 | |
| 		}
 | |
| 
 | |
| 		$content = Plugin::file_system()->get_contents($file);
 | |
| 		if (!$content) {
 | |
| 			return false;
 | |
| 		}
 | |
| 
 | |
| 		return [
 | |
| 			'content' => $content,
 | |
| 			'file'    => $file
 | |
| 		];
 | |
| 	}
 | |
| } |