*/
class Abovethefold_Critical_CSS
{
/**
* Above the fold controller
*/
public $CTRL;
/**
* Initialize the class and set its properties
*/
public function __construct(&$CTRL)
{
$this->CTRL = & $CTRL;
}
/**
* Get theme based critical CSS configuration
*/
public function get_theme_criticalcss()
{
$errors = array();
// get config cache
$fileconfig = get_option('abovethefold-criticalcss', array());
if (!is_array($fileconfig)) {
$fileconfig = array();
}
// read critical CSS files from theme directory (/abovethefold/critical-css/)
$criticalcss_dir = $this->CTRL->theme_path('critical-css');
$criticalcss_files = (is_dir($criticalcss_dir)) ? array_diff(scandir($criticalcss_dir), array('..', '.')) : array();
// update config?
$updated = false;
// process files
$newfileconfig = array();
foreach ($criticalcss_files as $file) {
if (substr($file, -4) !== '.css') {
// not a css file
continue 1;
}
// global critical CSS
if ($file === 'global.css') {
if (!isset($fileconfig[$file]) || filemtime($criticalcss_dir . $file) > $fileconfig[$file]['t']) {
$fileconfig[$file] = array(
'file' => $criticalcss_dir . $file,
't' => filemtime($criticalcss_dir . $file),
'weight' => 0
);
$updated = true;
}
$newfileconfig[$file] = $fileconfig[$file];
} else {
// check if the config should be updated
if (!isset($fileconfig[$file]) || filemtime($criticalcss_dir . $file) > $fileconfig[$file]['t']) {
$css = trim(file_get_contents($criticalcss_dir . $file));
if ($css !== '') {
if (!preg_match('|^/\*(.*?)\*/|is', $css, $output)) {
$errors[] = array(
'message' => 'Critical CSS contains no config header.
'
);
continue 1;
}
}
// name
if (preg_match('|^\s*\*[\s\*]+([^\n]+)|is', $output[1], $nameout)) {
$criticalcss_name = trim($nameout[1]);
} else {
$criticalcss_name = ucfirst(trim(preg_replace(array('|\.css$|Ui','|\-+|is'), array('',' '), $file)));
}
$config = array();
if (preg_match_all('|\@([^\@\s]*)(\s+([^\n\@]*))?\n|is', $output[1], $matches)) {
foreach ($matches[1] as $n => $key) {
$key = trim($key);
switch ($key) {
case "condition":
$key = 'conditions';
if (!isset($config[$key])) {
$config[$key] = array();
}
$condition = trim($matches[2][$n]);
if ($condition === '') {
continue 1; // ignore
}
if (strpos($condition, ':') !== false) {
$split = explode(':', $condition, 2);
$condition_key = $split[0];
$data = $split[1];
switch ($condition_key) {
case "filter":
if (strpos($data, ':') !== false) {
$datasplit = explode(':', $data, 2);
$filtername = $datasplit[0];
$data = @json_decode('[' . trim($datasplit[1]) . ']');
if (!is_array($data) || empty($data)) {
$errors[] = array(
'message' => 'Failed to parse filter params for filter '.htmlentities(trim($filtername), ENT_COMPAT, 'utf-8').' ('.htmlentities(trim($datasplit[1]), ENT_COMPAT, 'utf-8').'). The parameters should be JSON encoded, e.g. "param1","param2".'
);
continue 1;
}
foreach ($data as $dn => $datavalue) {
if (is_string($datavalue) && substr($datavalue, 0, 1) === '"') {
$data[$dn] = json_decode($datavalue);
}
}
} else {
$filtername = $data;
$data = false;
}
if (!isset($config[$key][$condition_key])) {
$config[$key][$condition_key] = array();
}
$config[$key][$condition_key][$filtername] = $data;
break;
default:
$data = @json_decode('[' . trim($split[1]) . ']');
if (!is_array($data) || empty($data)) {
$errors[] = array(
'message' => 'Failed to parse filter params for filter '.htmlentities(trim($filtername), ENT_COMPAT, 'utf-8').' ('.htmlentities(trim($datasplit[1]), ENT_COMPAT, 'utf-8').'). The parameters should be JSON encoded, e.g. "param1","param2".'
);
continue 1;
}
foreach ($data as $dn => $datavalue) {
if (is_string($datavalue) && substr($datavalue, 0, 1) === '"') {
$data[$dn] = json_decode($datavalue);
}
}
if (!isset($config[$key][$condition_key])) {
$config[$key][$condition_key] = array();
}
$config[$key][$condition_key] = array_unique(array_merge($config[$key][$condition_key], $data));
break;
}
} else {
//$condition = array($condition);
$condition_key = $condition;
$condition_data = false;
$config[$key][$condition_key] = $condition_data;
}
break;
case "append":
case "prepend":
if (!isset($config[$key])) {
$config[$key] = array();
}
$append = trim($matches[2][$n]);
// file
if ($append !== '' && strpos($append, '/') !== false) {
// resolve relative path from critical-css directory
if (substr($append, 0, 1) !== '/') {
$appendPath = $criticalcss_dir . $append;
}
$appendPath = realpath($appendPath);
if (strpos($appendPath, ABSPATH) === false) {
// not in WordPress root, ignore
wp_die('Critical CSS file '.$file.' contains append/prepend location outside WordPress root.');
}
if (!in_array($append, $config[$key])) {
$config[$key][] = $append;
}
} else {
if (!in_array($append, $config[$key])) {
$config[$key][] = $append;
}
}
break;
default:
$config[$key] = trim($matches[2][$n]);
break;
}
}
}
$config['name'] = $criticalcss_name;
$config['file'] = $criticalcss_dir . $file;
$config['t'] = filemtime($criticalcss_dir . $file);
$fileconfig[$file] = $config;
$updated = true;
}
if (isset($fileconfig[$file]['appendToAny'])) {
$fileconfig[$file]['appendToAny'] = true;
}
if (isset($fileconfig[$file]['prependToAny'])) {
$fileconfig[$file]['prependToAny'] = true;
}
if (isset($fileconfig[$file]['append']) && !empty($fileconfig[$file]['append'])) {
$fileconfig[$file]['append'] = array_unique($fileconfig[$file]['append']);
}
if (isset($fileconfig[$file]['prepend']) && !empty($fileconfig[$file]['prepend'])) {
$fileconfig[$file]['prepend'] = array_unique($fileconfig[$file]['prepend']);
}
$newfileconfig[$file] = $fileconfig[$file];
}
}
/**
* Cache critical CSS file config
*/
if ($updated || count($fileconfig) !== count($newfileconfig)) {
// sort based on weight
uasort($newfileconfig, function ($a, $b) {
if ($a['weight'] == $b['weight']) {
return 0;
}
return ($a['weight'] < $b['weight']) ? +1 : -1;
});
update_option('abovethefold-criticalcss', $newfileconfig, true);
return $newfileconfig;
} else {
return $fileconfig;
}
}
/**
* Get critical CSS file contents
*/
public function get_file_contents($file)
{
if (!file_exists($file)) {
return '';
}
// strip config header
$cssdata = trim(preg_replace('|^\s*/\*(.*?)\*/|is', '', trim(file_get_contents($file))));
return $cssdata;
}
/**
* Delete critical CSS file
*/
public function delete_file($file)
{
$criticalcss_dir = $this->CTRL->theme_path('critical-css');
if (strpos($file, '/') === false) {
$file = $criticalcss_dir . $file;
}
// verify if file is located in critical css directory
if (strpos($file, $criticalcss_dir) === false) {
wp_die('File to delete not located in critical CSS directory.');
}
// strip config header
if (file_exists($file)) {
@unlink($file);
}
}
/**
* Save critical CSS file contents
*/
public function save_file_contents($file, $config, $css)
{
$errors = array();
if (strpos($file, '/') !== false || substr($file, -4) !== '.css') {
$errors[] = array(
'message' => 'Invalid Critical CSS file: ' . htmlentities($file, ENT_COMPAT, 'utf-8')
);
return $errors;
}
if (!isset($config['name']) || trim($config['name']) === '') {
$config['name'] = str_replace('.css', '', $file);
}
// header
$cssheader = '/**
* ' . $config['name'] . '
*';
// conditions
$conditions = array();
if (isset($config['conditions']) && !empty($config['conditions'])) {
foreach ($config['conditions'] as $condition) {
if (trim($condition) === '') {
continue 1;
}
$split = explode(':', $condition, 2);
$condition_key = $split[0];
$data = isset($split[1]) ? $split[1] : false;
switch ($condition_key) {
case "filter":
if ($data && strpos($data, ':') !== false) {
$datasplit = explode(':', $data, 2);
$filtername = $datasplit[0];
if (trim($datasplit[1]) === '') {
$data = array();
} else {
$data = @json_decode('[' . trim($datasplit[1]) . ']');
if (!is_array($data) || empty($data)) {
$errors[] = array(
'message' => 'Failed to parse filter params for filter '.htmlentities(trim($filtername), ENT_COMPAT, 'utf-8').' ('.htmlentities(trim($datasplit[1]), ENT_COMPAT, 'utf-8').'). The parameters should be JSON encoded, e.g. "param1","param2".'
);
continue 1;
}
}
//$data = explode(',',$datasplit[1]);
foreach ($data as $dn => $datavalue) {
if (is_string($datavalue) && substr($datavalue, 0, 1) === '"') {
$data[$dn] = json_decode($datavalue);
}
}
} else {
$filtername = $data;
$data = false;
}
if (!isset($conditions[$condition_key])) {
$conditions[$condition_key] = array();
}
$conditions[$condition_key][$filtername] = $data;
break;
default:
if (!isset($conditions[$condition_key])) {
$conditions[$condition_key] = array();
}
if ($data) {
$conditions[$condition_key][] = $data;
}
break;
}
}
}
foreach ($conditions as $condition_key => $condition_data) {
switch ($condition_key) {
case "filter":
foreach ($condition_data as $filter_key => $filter_vars) {
$cssheader .= "\n * @condition filter:" . $filter_key;
if (is_array($filter_vars) && !empty($filter_vars)) {
foreach ($filter_vars as $dn => $datavalue) {
if (is_string($datavalue) && substr($datavalue, 0, 1) === '"') {
$filter_vars[$dn] = json_decode($datavalue);
}
}
$cssheader .= ':' . trim(json_encode($filter_vars, true), '[]');
}
}
break;
default:
$cssheader .= "\n * @condition " . $condition_key;
if (is_array($condition_data) && count($condition_data) > 0) {
$cssheader .= ':' . trim(json_encode($condition_data, true), '[]');
}
break;
}
}
if (isset($config['matchType']) && in_array($config['matchType'], array('any','all'))) {
$cssheader .= "\n * @matchType " . $config['matchType'];
}
if (isset($config['appendToAny'])) {
$cssheader .= "\n * @appendToAny";
}
if (isset($config['prependToAny'])) {
$cssheader .= "\n * @prependToAny";
}
// append/prepend
$preappend = array('append','prepend');
foreach ($preappend as $type) {
if (isset($config[$type]) && !empty($config[$type])) {
foreach ($config[$type] as $appendfile) {
if (trim($appendfile) === '') {
continue 1;
}
$cssheader .= "\n * @" . $type . " " . $appendfile;
}
}
}
if (isset($config['weight'])) {
$cssheader .= "\n * @weight " . ((is_numeric($config['weight']) && $config['weight'] >= 1) ? $config['weight'] : 1);
}
$cssheader = trim($cssheader);
if (substr($cssheader, -1) === '*') {
$cssheader = trim(substr($cssheader, 0, -1));
}
$cssheader .= "\n */\n";
$criticalcss_dir = $this->CTRL->theme_path('critical-css');
$this->CTRL->file_put_contents($criticalcss_dir . $file, $cssheader . $css);
return (!empty($errors)) ? $errors : false;
}
/**
* Retrieve critical CSS for environment conditions
*/
public function get()
{
// debug
$debug = (current_user_can('administrator') || current_user_can('editor')) ? true : false;
$debug_enabled = ($debug && isset($this->CTRL->options['debug']) && intval($this->CTRL->options['debug']) === 1) ? true : false;
$criticalcss_files = $this->get_theme_criticalcss();
$criticalcss_dir = $this->CTRL->theme_path('critical-css');
$primary_criticalcss = array();
$preAppendFiles = array();
$debug_criticalcss = ($this->CTRL->view === 'critical-css-view');
if (!empty($criticalcss_files)) {
// match conditional CSS
foreach ($criticalcss_files as $file => $config) {
// no conditions, skip
if ($file === 'global.css') {
continue 1;
}
// critical css already matched and no append/prepend to any, skip
if ($primary_criticalcss && (!isset($config['appendToAny']) && !isset($config['prependToAny']))) {
continue 1;
}
$matchType = (isset($config['matchType']) && strtolower($config['matchType']) === 'all') ? 'all' : 'any';
$match = false;
$matchAll = true;
$matchedConditions = array();
// no conditions: match
if (!isset($config['conditions']) || empty($config['conditions'])) {
$match = true;
} else {
foreach ($config['conditions'] as $condition_key => $data) {
switch ($condition_key) {
case "filter":
foreach ($data as $filter => $filterdata) {
if (function_exists($filter)) {
$matchRes = call_user_func($filter, $filterdata);
if ($matchRes === true) {
// match
$match = true;
} else {
$matchAll = false;
}
$matchedConditions[] = $filter;
}
}
break;
default:
$condition_fn = rtrim($condition_key, '()');
if (!function_exists($condition_fn)) {
// condition not recognized
continue 1;
}
$skip = false;
switch ($condition_fn) {
case "has_category":
if (!is_single()) {
$skip = true;
}
break;
}
if ($skip) {
continue 1;
}
if (call_user_func($condition_fn, (($data) ? $data : null))) {
// match
$match = true;
$matchedConditions[] = $condition_key;
} else {
$matchAll = false;
}
break;
}
}
}
// all conditions must match
if ($matchType === 'all' && !$matchAll) {
$match = false;
}
// found matching condition
if ($match) {
$cssdata = $this->get_file_contents($config['file']);
if ($cssdata === '') {
// empty
continue 1;
}
$csshash = md5($cssdata);
if ($debug_criticalcss) {
$cssdata = "\n\n/*\n * @critical-css-file " . basename($config['file']) . "\n */\n" . $cssdata;
}
$appendPrepend = false;
$preappend = array('append','prepend');
foreach ($preappend as $type) {
// append/prepend to any other matching file or global.css
if (isset($config[$type . 'ToAny']) && $config[$type . 'ToAny']) {
if (!isset($preAppendFiles[$type])) {
$preAppendFiles[$type] = array();
}
$preAppendFiles[$type][$file] = $cssdata;
$appendPrepend = true;
}
// append files
if (isset($config[$type]) && !empty($config[$type])) {
foreach ($config[$type] as $append) {
$appendcss = '';
if (strpos($append, '/') !== false) {
if (substr($append, 0, 1) !== '/') {
$appendPath = $criticalcss_dir . $append;
} else {
$appendPath = $append;
}
$appendcss = $this->get_file_contents($appendPath);
} else {
if (isset($criticalcss_files[$append]) && file_exists($criticalcss_files[$append]['file'])) {
$appendcss = $this->get_file_contents($criticalcss_files[$append]['file']);
}
}
if ($appendcss !== '') {
$preAppendFiles[$type][$append] = $appendcss;
}
}
}
}
if (!$appendPrepend) {
$primary_criticalcss = array(
'css' => $cssdata,
'file' => basename($config['file']),
'match' => $matchedConditions
);
}
}
}
}
// no matching primary critical css, use global.css
if (!$primary_criticalcss) {
$cssdata = (isset($criticalcss_files['global.css']) ? $this->get_file_contents($criticalcss_files['global.css']['file']) : '');
if ($debug_criticalcss) {
$cssdata = "\n\n/*\n * @critical-css-file global.css\n */\n" . $cssdata;
}
$primary_criticalcss = $primary_criticalcss = array(
'css' => $cssdata,
'file' => 'global.css',
'match' => false
);
}
$servedfiles = '';
if (isset($preAppendFiles['prepend']) && !empty($preAppendFiles['prepend'])) {
$primary_criticalcss['css'] = implode(' ', array_values($preAppendFiles['prepend'])) . ' ' . $primary_criticalcss['css'];
$files = array_keys($preAppendFiles['prepend']);
foreach ($files as $file) {
if ($servedfiles !== '') {
$servedfiles .= "\n";
}
$servedfiles .= ' * @prepended ' . $file;
}
}
if ($servedfiles !== '') {
$servedfiles .= "\n";
}
$servedfiles .= ' * @primary ' . $primary_criticalcss['file'];
if (isset($preAppendFiles['append']) && !empty($preAppendFiles['append'])) {
$primary_criticalcss['css'] = $primary_criticalcss['css'] . ' ' . implode(' ', array_values($preAppendFiles['append']));
$files = array_keys($preAppendFiles['append']);
foreach ($files as $file) {
if ($servedfiles !== '') {
$servedfiles .= "\n";
}
$servedfiles .= ' * @appended ' . $file;
}
}
$matchedconditions = '';
if ($primary_criticalcss['match']) {
foreach ($primary_criticalcss['match'] as $match) {
$matchedconditions .= "\n" . ' * @condition ' . $match;
}
}
$debugnotice = (($debug_enabled) ? "\n" . ' * @debug enabled' : '');
$primary_criticalcss['css'] = trim($primary_criticalcss['css']);
// critical css
$criticalCSS = '';
if ($debug_criticalcss) {
$criticalCSS .= "/**\n * Critical CSS Editor\n *\n * The extracted Critical CSS has been annotated with file references for easy editing. \n * The Critical CSS source files are located in the theme directory .../".basename(get_stylesheet_directory())."/abovethefold/critical-css/\n */\n\n";
}
/**
* Hide Critical CSS for verification view
*/
if ($this->CTRL->view === 'no-critical-css' || $this->CTRL->view === 'critical-css-creator-html') {
if ($debug) {
$criticalCSS .= '
/*!
* Page Speed Optimization ' . $this->CTRL->get_version() . '
* Full CSS View: Critical CSS is excluded from page.
*/
';
}
}
/**
* Include inline CSS
*/
elseif ($primary_criticalcss['css'] !== '') {
/**
* Debug header
*/
if ($debug) {
$criticalCSS .= '
/*!
* Page Speed Optimization ' . $this->CTRL->get_version() . '
* This message is visible to admins and editors only.
*
' . htmlentities($servedfiles, ENT_COMPAT, 'utf-8') . $matchedconditions . $debugnotice . '
*/
' . $primary_criticalcss['css'];
} else {
$criticalCSS .= $primary_criticalcss['css'];
}
} else {
/**
* Print warning when Critical CSS is empty
*/
$criticalCSS .= '
/*!
* Page Speed Optimization ' . $this->CTRL->get_version() . '
*
* ------------------------------------
* WARNING: CRITICAL CSS IS EMPTY
* ------------------------------------
*/
';
}
return $criticalCSS;
}
/**
* HTTP/2 Server Push file from critical CSS
*/
public function http2_push_file($css)
{
$css = trim($css);
if ($css === '') {
return false;
}
// file hash
$hash = md5($css);
$cache_file = $this->http2_cache_file_path($hash);
// write critical css
if (!file_exists($cache_file) || filemtime($cache_file) < (time() - 86400)) {
$this->CTRL->file_put_contents($cache_file, $css);
}
return $this->http2_cache_url($hash);
}
/**
* Prune expired HTTP/2 cache entries
*/
public function http2_cache_prune()
{
// age to delete cache file
$prune_age = 7 * 86400; // 1 week
$prune_time = (time() - $prune_age);
$file_count = 0;
$file_size = 0;
$deleted_count = 0;
$cache_path = $this->CTRL->cache_path('http2_css');
$root_dir = array_diff(scandir($cache_path), array('..', '.'));
foreach ($root_dir as $dirA) {
if (strlen($dirA) === 2 && is_dir($cache_path . $dirA . '/')) {
$A_dir = array_diff(scandir($cache_path . $dirA . '/'), array('..', '.'));
foreach ($A_dir as $dirB) {
if (strlen($dirB) === 2 && is_dir($cache_path . $dirA . '/' . $dirB . '/')) {
$C_dir = array_diff(scandir($cache_path . $dirA . '/' . $dirB . '/'), array('..', '.'));
foreach ($C_dir as $dirC) {
if (strlen($dirC) === 2 && is_dir($cache_path . $dirA . '/' . $dirB . '/' . $dirC . '/')) {
$C_cache_path = $cache_path . $dirA . '/' . $dirB . '/' . $dirC . '/';
$cache_files = array_diff(scandir($C_cache_path), array('..', '.'));
foreach ($cache_files as $file) {
// date created
$date_created = filemtime($C_cache_path . $file);
// older than min age, delete cache file
if ($date_created < $prune_time) {
@unlink($C_cache_path . $file);
$deleted_count++;
} else {
$file_count++;
$file_size += filesize($C_cache_path . $file);
}
}
}
}
}
}
}
}
}
/**
* Empty HTTP/2 cache directory
*/
public function http2_empty_cache()
{
$cache_path = $this->CTRL->cache_path('http2_css');
$root_dir = array_diff(scandir($cache_path), array('..', '.'));
foreach ($root_dir as $dirA) {
$this->CTRL->rmdir($cache_path . $dirA . '/');
}
}
/**
* HTTP/2 Cache file path
*/
public function http2_cache_file_path($hash, $create = true)
{
// verify hash
if (strlen($hash) !== 32) {
$this->forbidden('Invalid cache file hash');
}
// Initialize cache path
$cache_path = $this->CTRL->cache_path('http2_css');
if (!is_dir($cache_path)) {
$this->error('HTTP/2 CSS cache directory not available ' . $cache_path);
}
$dir_blocks = array_slice(str_split($hash, 2), 0, 3);
foreach ($dir_blocks as $block) {
$cache_path .= $block . '/';
if (!is_dir($cache_path)) {
if (!$create) {
return false;
} else {
if (!$this->CTRL->mkdir($cache_path)) {
$this->error('Failed to create directory ' . $cache_path);
}
}
}
}
$cache_path .= $hash . '.css';
if (!$create && !file_exists($cache_path)) {
return false;
}
return $cache_path;
}
/**
* Cache url
*/
public function http2_cache_url($hash, $urlExpiredCheck = false, $cdn = false)
{
// verify hash
if (strlen($hash) !== 32) {
$this->forbidden('Invalid cache file hash');
}
// check if cache file exists
$cache_path = $this->http2_cache_file_path($hash, false);
if (!$cache_path) {
return false;
}
$url = $this->CTRL->cache_dir('http2_css');
$dir_blocks = array_slice(str_split($hash, 2), 0, 3);
foreach ($dir_blocks as $block) {
$url .= $block . '/';
}
$url .= $hash . '.css';
return $url;
}
/**
* Cron prune method
*/
public function http2_cache_cron_prune()
{
// cron logfile
$cache_path = $this->CTRL->cache_path('http2_css');
$cronlog = $cache_path . 'cleanup_cron.log';
$this->CTRL->file_put_contents($cronlog, 'start: ' . date('r'));
// prune cache
$stats = $this->http2_cache_prune();
// log result
$this->CTRL->file_put_contents($cronlog, 'completed: ' . date('r') . "\nDeleted: " . $stats['deleted'] . "\nFiles: " . $stats['files'] . "\nSize: " . $stats['size']);
}
}