518 lines
14 KiB
PHP
518 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* SCSSPHP
|
|
*
|
|
* @copyright 2012-2017 Leaf Corcoran
|
|
*
|
|
* @license http://opensource.org/licenses/MIT MIT
|
|
*
|
|
* @link http://leafo.github.io/scssphp
|
|
*/
|
|
|
|
namespace Leafo\ScssPhp;
|
|
|
|
use Leafo\ScssPhp\Compiler;
|
|
use Leafo\ScssPhp\Exception\ServerException;
|
|
use Leafo\ScssPhp\Version;
|
|
|
|
/**
|
|
* Server
|
|
*
|
|
* @author Leaf Corcoran <leafot@gmail.com>
|
|
*/
|
|
class Server
|
|
{
|
|
/**
|
|
* @var boolean
|
|
*/
|
|
private $showErrorsAsCSS;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
private $dir;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
private $cacheDir;
|
|
|
|
/**
|
|
* @var \Leafo\ScssPhp\Compiler
|
|
*/
|
|
private $scss;
|
|
|
|
/**
|
|
* Join path components
|
|
*
|
|
* @param string $left Path component, left of the directory separator
|
|
* @param string $right Path component, right of the directory separator
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function join($left, $right)
|
|
{
|
|
return rtrim($left, '/\\') . DIRECTORY_SEPARATOR . ltrim($right, '/\\');
|
|
}
|
|
|
|
/**
|
|
* Get name of requested .scss file
|
|
*
|
|
* @return string|null
|
|
*/
|
|
protected function inputName()
|
|
{
|
|
switch (true) {
|
|
case isset($_GET['p']):
|
|
return $_GET['p'];
|
|
case isset($_SERVER['PATH_INFO']):
|
|
return $_SERVER['PATH_INFO'];
|
|
case isset($_SERVER['DOCUMENT_URI']):
|
|
return substr($_SERVER['DOCUMENT_URI'], strlen($_SERVER['SCRIPT_NAME']));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get path to requested .scss file
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function findInput()
|
|
{
|
|
if (($input = $this->inputName())
|
|
&& strpos($input, '..') === false
|
|
&& substr($input, -5) === '.scss'
|
|
) {
|
|
$name = $this->join($this->dir, $input);
|
|
|
|
if (is_file($name) && is_readable($name)) {
|
|
return $name;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get path to cached .css file
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function cacheName($fname)
|
|
{
|
|
return $this->join($this->cacheDir, md5($fname) . '.css');
|
|
}
|
|
|
|
/**
|
|
* Get path to meta data
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function metadataName($out)
|
|
{
|
|
return $out . '.meta';
|
|
}
|
|
|
|
/**
|
|
* Determine whether .scss file needs to be re-compiled.
|
|
*
|
|
* @param string $out Output path
|
|
* @param string $etag ETag
|
|
*
|
|
* @return boolean True if compile required.
|
|
*/
|
|
protected function needsCompile($out, &$etag)
|
|
{
|
|
if (! is_file($out)) {
|
|
return true;
|
|
}
|
|
|
|
$mtime = filemtime($out);
|
|
|
|
$metadataName = $this->metadataName($out);
|
|
|
|
if (is_readable($metadataName)) {
|
|
$metadata = unserialize(file_get_contents($metadataName));
|
|
|
|
foreach ($metadata['imports'] as $import => $originalMtime) {
|
|
$currentMtime = filemtime($import);
|
|
|
|
if ($currentMtime !== $originalMtime || $currentMtime > $mtime) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$metaVars = crc32(serialize($this->scss->getVariables()));
|
|
|
|
if ($metaVars !== $metadata['vars']) {
|
|
return true;
|
|
}
|
|
|
|
$etag = $metadata['etag'];
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get If-Modified-Since header from client request
|
|
*
|
|
* @return string|null
|
|
*/
|
|
protected function getIfModifiedSinceHeader()
|
|
{
|
|
$modifiedSince = null;
|
|
|
|
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
|
|
$modifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'];
|
|
|
|
if (false !== ($semicolonPos = strpos($modifiedSince, ';'))) {
|
|
$modifiedSince = substr($modifiedSince, 0, $semicolonPos);
|
|
}
|
|
}
|
|
|
|
return $modifiedSince;
|
|
}
|
|
|
|
/**
|
|
* Get If-None-Match header from client request
|
|
*
|
|
* @return string|null
|
|
*/
|
|
protected function getIfNoneMatchHeader()
|
|
{
|
|
$noneMatch = null;
|
|
|
|
if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
|
|
$noneMatch = $_SERVER['HTTP_IF_NONE_MATCH'];
|
|
}
|
|
|
|
return $noneMatch;
|
|
}
|
|
|
|
/**
|
|
* Compile .scss file
|
|
*
|
|
* @param string $in Input path (.scss)
|
|
* @param string $out Output path (.css)
|
|
*
|
|
* @return array
|
|
*/
|
|
protected function compile($in, $out)
|
|
{
|
|
$start = microtime(true);
|
|
$css = $this->scss->compile(file_get_contents($in), $in);
|
|
$elapsed = round((microtime(true) - $start), 4);
|
|
|
|
$v = Version::VERSION;
|
|
$t = date('r');
|
|
$css = "/* compiled by scssphp $v on $t (${elapsed}s) */\n\n" . $css;
|
|
$etag = md5($css);
|
|
|
|
file_put_contents($out, $css);
|
|
file_put_contents(
|
|
$this->metadataName($out),
|
|
serialize([
|
|
'etag' => $etag,
|
|
'imports' => $this->scss->getParsedFiles(),
|
|
'vars' => crc32(serialize($this->scss->getVariables())),
|
|
])
|
|
);
|
|
|
|
return [$css, $etag];
|
|
}
|
|
|
|
/**
|
|
* Format error as a pseudo-element in CSS
|
|
*
|
|
* @param \Exception $error
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function createErrorCSS(\Exception $error)
|
|
{
|
|
$message = str_replace(
|
|
["'", "\n"],
|
|
["\\'", "\\A"],
|
|
$error->getfile() . ":\n\n" . $error->getMessage()
|
|
);
|
|
|
|
return "body { display: none !important; }
|
|
html:after {
|
|
background: white;
|
|
color: black;
|
|
content: '$message';
|
|
display: block !important;
|
|
font-family: mono;
|
|
padding: 1em;
|
|
white-space: pre;
|
|
}";
|
|
}
|
|
|
|
/**
|
|
* Render errors as a pseudo-element within valid CSS, displaying the errors on any
|
|
* page that includes this CSS.
|
|
*
|
|
* @param boolean $show
|
|
*/
|
|
public function showErrorsAsCSS($show = true)
|
|
{
|
|
$this->showErrorsAsCSS = $show;
|
|
}
|
|
|
|
/**
|
|
* Compile .scss file
|
|
*
|
|
* @param string $in Input file (.scss)
|
|
* @param string $out Output file (.css) optional
|
|
*
|
|
* @return string|bool
|
|
*
|
|
* @throws \Leafo\ScssPhp\Exception\ServerException
|
|
*/
|
|
public function compileFile($in, $out = null)
|
|
{
|
|
if (! is_readable($in)) {
|
|
throw new ServerException('load error: failed to find ' . $in);
|
|
}
|
|
|
|
$pi = pathinfo($in);
|
|
|
|
$this->scss->addImportPath($pi['dirname'] . '/');
|
|
|
|
$compiled = $this->scss->compile(file_get_contents($in), $in);
|
|
|
|
if ($out !== null) {
|
|
return file_put_contents($out, $compiled);
|
|
}
|
|
|
|
return $compiled;
|
|
}
|
|
|
|
/**
|
|
* Check if file need compiling
|
|
*
|
|
* @param string $in Input file (.scss)
|
|
* @param string $out Output file (.css)
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function checkedCompile($in, $out)
|
|
{
|
|
if (! is_file($out) || filemtime($in) > filemtime($out)) {
|
|
$this->compileFile($in, $out);
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Compile requested scss and serve css. Outputs HTTP response.
|
|
*
|
|
* @param string $salt Prefix a string to the filename for creating the cache name hash
|
|
*/
|
|
public function serve($salt = '')
|
|
{
|
|
$protocol = isset($_SERVER['SERVER_PROTOCOL'])
|
|
? $_SERVER['SERVER_PROTOCOL']
|
|
: 'HTTP/1.0';
|
|
|
|
if ($input = $this->findInput()) {
|
|
$output = $this->cacheName($salt . $input);
|
|
$etag = $noneMatch = trim($this->getIfNoneMatchHeader(), '"');
|
|
|
|
if ($this->needsCompile($output, $etag)) {
|
|
try {
|
|
list($css, $etag) = $this->compile($input, $output);
|
|
|
|
$lastModified = gmdate('D, d M Y H:i:s', filemtime($output)) . ' GMT';
|
|
|
|
header('Last-Modified: ' . $lastModified);
|
|
header('Content-type: text/css');
|
|
header('ETag: "' . $etag . '"');
|
|
|
|
echo $css;
|
|
} catch (\Exception $e) {
|
|
if ($this->showErrorsAsCSS) {
|
|
header('Content-type: text/css');
|
|
|
|
echo $this->createErrorCSS($e);
|
|
} else {
|
|
header($protocol . ' 500 Internal Server Error');
|
|
header('Content-type: text/plain');
|
|
|
|
echo 'Parse error: ' . $e->getMessage() . "\n";
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
header('X-SCSS-Cache: true');
|
|
header('Content-type: text/css');
|
|
header('ETag: "' . $etag . '"');
|
|
|
|
if ($etag === $noneMatch) {
|
|
header($protocol . ' 304 Not Modified');
|
|
|
|
return;
|
|
}
|
|
|
|
$modifiedSince = $this->getIfModifiedSinceHeader();
|
|
$mtime = filemtime($output);
|
|
|
|
if (strtotime($modifiedSince) === $mtime) {
|
|
header($protocol . ' 304 Not Modified');
|
|
|
|
return;
|
|
}
|
|
|
|
$lastModified = gmdate('D, d M Y H:i:s', $mtime) . ' GMT';
|
|
header('Last-Modified: ' . $lastModified);
|
|
|
|
echo file_get_contents($output);
|
|
|
|
return;
|
|
}
|
|
|
|
header($protocol . ' 404 Not Found');
|
|
header('Content-type: text/plain');
|
|
|
|
$v = Version::VERSION;
|
|
echo "/* INPUT NOT FOUND scss $v */\n";
|
|
}
|
|
|
|
/**
|
|
* Based on explicit input/output files does a full change check on cache before compiling.
|
|
*
|
|
* @param string $in
|
|
* @param string $out
|
|
* @param boolean $force
|
|
*
|
|
* @return string Compiled CSS results
|
|
*
|
|
* @throws \Leafo\ScssPhp\Exception\ServerException
|
|
*/
|
|
public function checkedCachedCompile($in, $out, $force = false)
|
|
{
|
|
if (! is_file($in) || ! is_readable($in)) {
|
|
throw new ServerException('Invalid or unreadable input file specified.');
|
|
}
|
|
|
|
if (is_dir($out) || ! is_writable(file_exists($out) ? $out : dirname($out))) {
|
|
throw new ServerException('Invalid or unwritable output file specified.');
|
|
}
|
|
|
|
if ($force || $this->needsCompile($out, $etag)) {
|
|
list($css, $etag) = $this->compile($in, $out);
|
|
} else {
|
|
$css = file_get_contents($out);
|
|
}
|
|
|
|
return $css;
|
|
}
|
|
|
|
/**
|
|
* Execute scssphp on a .scss file or a scssphp cache structure
|
|
*
|
|
* The scssphp cache structure contains information about a specific
|
|
* scss file having been parsed. It can be used as a hint for future
|
|
* calls to determine whether or not a rebuild is required.
|
|
*
|
|
* The cache structure contains two important keys that may be used
|
|
* externally:
|
|
*
|
|
* compiled: The final compiled CSS
|
|
* updated: The time (in seconds) the CSS was last compiled
|
|
*
|
|
* The cache structure is a plain-ol' PHP associative array and can
|
|
* be serialized and unserialized without a hitch.
|
|
*
|
|
* @param mixed $in Input
|
|
* @param boolean $force Force rebuild?
|
|
*
|
|
* @return array scssphp cache structure
|
|
*/
|
|
public function cachedCompile($in, $force = false)
|
|
{
|
|
// assume no root
|
|
$root = null;
|
|
|
|
if (is_string($in)) {
|
|
$root = $in;
|
|
} elseif (is_array($in) and isset($in['root'])) {
|
|
if ($force or ! isset($in['files'])) {
|
|
// If we are forcing a recompile or if for some reason the
|
|
// structure does not contain any file information we should
|
|
// specify the root to trigger a rebuild.
|
|
$root = $in['root'];
|
|
} elseif (isset($in['files']) and is_array($in['files'])) {
|
|
foreach ($in['files'] as $fname => $ftime) {
|
|
if (! file_exists($fname) or filemtime($fname) > $ftime) {
|
|
// One of the files we knew about previously has changed
|
|
// so we should look at our incoming root again.
|
|
$root = $in['root'];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// TODO: Throw an exception? We got neither a string nor something
|
|
// that looks like a compatible lessphp cache structure.
|
|
return null;
|
|
}
|
|
|
|
if ($root !== null) {
|
|
// If we have a root value which means we should rebuild.
|
|
$out = array();
|
|
$out['root'] = $root;
|
|
$out['compiled'] = $this->compileFile($root);
|
|
$out['files'] = $this->scss->getParsedFiles();
|
|
$out['updated'] = time();
|
|
return $out;
|
|
} else {
|
|
// No changes, pass back the structure
|
|
// we were given initially.
|
|
return $in;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param string $dir Root directory to .scss files
|
|
* @param string $cacheDir Cache directory
|
|
* @param \Leafo\ScssPhp\Compiler|null $scss SCSS compiler instance
|
|
*/
|
|
public function __construct($dir, $cacheDir = null, $scss = null)
|
|
{
|
|
$this->dir = $dir;
|
|
|
|
if (! isset($cacheDir)) {
|
|
$cacheDir = $this->join($dir, 'scss_cache');
|
|
}
|
|
|
|
$this->cacheDir = $cacheDir;
|
|
|
|
if (! is_dir($this->cacheDir)) {
|
|
throw new ServerException('Cache directory doesn\'t exist: ' . $cacheDir);
|
|
}
|
|
|
|
if (! isset($scss)) {
|
|
$scss = new Compiler();
|
|
$scss->setImportPaths($this->dir);
|
|
}
|
|
|
|
$this->scss = $scss;
|
|
$this->showErrorsAsCSS = false;
|
|
|
|
if (! ini_get('date.timezone')) {
|
|
throw new ServerException('Default date.timezone not set');
|
|
}
|
|
}
|
|
}
|