Merge branch '3.1' into 5.2
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / api / leafo / scssphp / example / Server.php
1 <?php
2 /**
3 * SCSSPHP
4 *
5 * @copyright 2012-2017 Leaf Corcoran
6 *
7 * @license http://opensource.org/licenses/MIT MIT
8 *
9 * @link http://leafo.github.io/scssphp
10 */
11
12 namespace Leafo\ScssPhp;
13
14 use Leafo\ScssPhp\Compiler;
15 use Leafo\ScssPhp\Exception\ServerException;
16 use Leafo\ScssPhp\Version;
17
18 /**
19 * Server
20 *
21 * @author Leaf Corcoran <leafot@gmail.com>
22 */
23 class Server
24 {
25 /**
26 * @var boolean
27 */
28 private $showErrorsAsCSS;
29
30 /**
31 * @var string
32 */
33 private $dir;
34
35 /**
36 * @var string
37 */
38 private $cacheDir;
39
40 /**
41 * @var \Leafo\ScssPhp\Compiler
42 */
43 private $scss;
44
45 /**
46 * Join path components
47 *
48 * @param string $left Path component, left of the directory separator
49 * @param string $right Path component, right of the directory separator
50 *
51 * @return string
52 */
53 protected function join($left, $right)
54 {
55 return rtrim($left, '/\\') . DIRECTORY_SEPARATOR . ltrim($right, '/\\');
56 }
57
58 /**
59 * Get name of requested .scss file
60 *
61 * @return string|null
62 */
63 protected function inputName()
64 {
65 switch (true) {
66 case isset($_GET['p']):
67 return $_GET['p'];
68 case isset($_SERVER['PATH_INFO']):
69 return $_SERVER['PATH_INFO'];
70 case isset($_SERVER['DOCUMENT_URI']):
71 return substr($_SERVER['DOCUMENT_URI'], strlen($_SERVER['SCRIPT_NAME']));
72 }
73 }
74
75 /**
76 * Get path to requested .scss file
77 *
78 * @return string
79 */
80 protected function findInput()
81 {
82 if (($input = $this->inputName())
83 && strpos($input, '..') === false
84 && substr($input, -5) === '.scss'
85 ) {
86 $name = $this->join($this->dir, $input);
87
88 if (is_file($name) && is_readable($name)) {
89 return $name;
90 }
91 }
92
93 return false;
94 }
95
96 /**
97 * Get path to cached .css file
98 *
99 * @return string
100 */
101 protected function cacheName($fname)
102 {
103 return $this->join($this->cacheDir, md5($fname) . '.css');
104 }
105
106 /**
107 * Get path to meta data
108 *
109 * @return string
110 */
111 protected function metadataName($out)
112 {
113 return $out . '.meta';
114 }
115
116 /**
117 * Determine whether .scss file needs to be re-compiled.
118 *
119 * @param string $out Output path
120 * @param string $etag ETag
121 *
122 * @return boolean True if compile required.
123 */
124 protected function needsCompile($out, &$etag)
125 {
126 if (! is_file($out)) {
127 return true;
128 }
129
130 $mtime = filemtime($out);
131
132 $metadataName = $this->metadataName($out);
133
134 if (is_readable($metadataName)) {
135 $metadata = unserialize(file_get_contents($metadataName));
136
137 foreach ($metadata['imports'] as $import => $originalMtime) {
138 $currentMtime = filemtime($import);
139
140 if ($currentMtime !== $originalMtime || $currentMtime > $mtime) {
141 return true;
142 }
143 }
144
145 $metaVars = crc32(serialize($this->scss->getVariables()));
146
147 if ($metaVars !== $metadata['vars']) {
148 return true;
149 }
150
151 $etag = $metadata['etag'];
152
153 return false;
154 }
155
156 return true;
157 }
158
159 /**
160 * Get If-Modified-Since header from client request
161 *
162 * @return string|null
163 */
164 protected function getIfModifiedSinceHeader()
165 {
166 $modifiedSince = null;
167
168 if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
169 $modifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'];
170
171 if (false !== ($semicolonPos = strpos($modifiedSince, ';'))) {
172 $modifiedSince = substr($modifiedSince, 0, $semicolonPos);
173 }
174 }
175
176 return $modifiedSince;
177 }
178
179 /**
180 * Get If-None-Match header from client request
181 *
182 * @return string|null
183 */
184 protected function getIfNoneMatchHeader()
185 {
186 $noneMatch = null;
187
188 if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
189 $noneMatch = $_SERVER['HTTP_IF_NONE_MATCH'];
190 }
191
192 return $noneMatch;
193 }
194
195 /**
196 * Compile .scss file
197 *
198 * @param string $in Input path (.scss)
199 * @param string $out Output path (.css)
200 *
201 * @return array
202 */
203 protected function compile($in, $out)
204 {
205 $start = microtime(true);
206 $css = $this->scss->compile(file_get_contents($in), $in);
207 $elapsed = round((microtime(true) - $start), 4);
208
209 $v = Version::VERSION;
210 $t = gmdate('r');
211 $css = "/* compiled by scssphp $v on $t (${elapsed}s) */\n\n" . $css;
212 $etag = md5($css);
213
214 file_put_contents($out, $css);
215 file_put_contents(
216 $this->metadataName($out),
217 serialize([
218 'etag' => $etag,
219 'imports' => $this->scss->getParsedFiles(),
220 'vars' => crc32(serialize($this->scss->getVariables())),
221 ])
222 );
223
224 return [$css, $etag];
225 }
226
227 /**
228 * Format error as a pseudo-element in CSS
229 *
230 * @param \Exception $error
231 *
232 * @return string
233 */
234 protected function createErrorCSS(\Exception $error)
235 {
236 $message = str_replace(
237 ["'", "\n"],
238 ["\\'", "\\A"],
239 $error->getfile() . ":\n\n" . $error->getMessage()
240 );
241
242 return "body { display: none !important; }
243 html:after {
244 background: white;
245 color: black;
246 content: '$message';
247 display: block !important;
248 font-family: mono;
249 padding: 1em;
250 white-space: pre;
251 }";
252 }
253
254 /**
255 * Render errors as a pseudo-element within valid CSS, displaying the errors on any
256 * page that includes this CSS.
257 *
258 * @param boolean $show
259 */
260 public function showErrorsAsCSS($show = true)
261 {
262 $this->showErrorsAsCSS = $show;
263 }
264
265 /**
266 * Compile .scss file
267 *
268 * @param string $in Input file (.scss)
269 * @param string $out Output file (.css) optional
270 *
271 * @return string|bool
272 *
273 * @throws \Leafo\ScssPhp\Exception\ServerException
274 */
275 public function compileFile($in, $out = null)
276 {
277 if (! is_readable($in)) {
278 throw new ServerException('load error: failed to find ' . $in);
279 }
280
281 $pi = pathinfo($in);
282
283 $this->scss->addImportPath($pi['dirname'] . '/');
284
285 $compiled = $this->scss->compile(file_get_contents($in), $in);
286
287 if ($out !== null) {
288 return file_put_contents($out, $compiled);
289 }
290
291 return $compiled;
292 }
293
294 /**
295 * Check if file need compiling
296 *
297 * @param string $in Input file (.scss)
298 * @param string $out Output file (.css)
299 *
300 * @return bool
301 */
302 public function checkedCompile($in, $out)
303 {
304 if (! is_file($out) || filemtime($in) > filemtime($out)) {
305 $this->compileFile($in, $out);
306
307 return true;
308 }
309
310 return false;
311 }
312
313 /**
314 * Compile requested scss and serve css. Outputs HTTP response.
315 *
316 * @param string $salt Prefix a string to the filename for creating the cache name hash
317 */
318 public function serve($salt = '')
319 {
320 $protocol = isset($_SERVER['SERVER_PROTOCOL'])
321 ? $_SERVER['SERVER_PROTOCOL']
322 : 'HTTP/1.0';
323
324 if ($input = $this->findInput()) {
325 $output = $this->cacheName($salt . $input);
326 $etag = $noneMatch = trim($this->getIfNoneMatchHeader(), '"');
327
328 if ($this->needsCompile($output, $etag)) {
329 try {
330 list($css, $etag) = $this->compile($input, $output);
331
332 $lastModified = gmdate('r', filemtime($output));
333
334 header('Last-Modified: ' . $lastModified);
335 header('Content-type: text/css');
336 header('ETag: "' . $etag . '"');
337
338 echo $css;
339 } catch (\Exception $e) {
340 if ($this->showErrorsAsCSS) {
341 header('Content-type: text/css');
342
343 echo $this->createErrorCSS($e);
344 } else {
345 header($protocol . ' 500 Internal Server Error');
346 header('Content-type: text/plain');
347
348 echo 'Parse error: ' . $e->getMessage() . "\n";
349 }
350 }
351
352 return;
353 }
354
355 header('X-SCSS-Cache: true');
356 header('Content-type: text/css');
357 header('ETag: "' . $etag . '"');
358
359 if ($etag === $noneMatch) {
360 header($protocol . ' 304 Not Modified');
361
362 return;
363 }
364
365 $modifiedSince = $this->getIfModifiedSinceHeader();
366 $mtime = filemtime($output);
367
368 if (strtotime($modifiedSince) === $mtime) {
369 header($protocol . ' 304 Not Modified');
370
371 return;
372 }
373
374 $lastModified = gmdate('r', $mtime);
375 header('Last-Modified: ' . $lastModified);
376
377 echo file_get_contents($output);
378
379 return;
380 }
381
382 header($protocol . ' 404 Not Found');
383 header('Content-type: text/plain');
384
385 $v = Version::VERSION;
386 echo "/* INPUT NOT FOUND scss $v */\n";
387 }
388
389 /**
390 * Based on explicit input/output files does a full change check on cache before compiling.
391 *
392 * @param string $in
393 * @param string $out
394 * @param boolean $force
395 *
396 * @return string Compiled CSS results
397 *
398 * @throws \Leafo\ScssPhp\Exception\ServerException
399 */
400 public function checkedCachedCompile($in, $out, $force = false)
401 {
402 if (! is_file($in) || ! is_readable($in)) {
403 throw new ServerException('Invalid or unreadable input file specified.');
404 }
405
406 if (is_dir($out) || ! is_writable(file_exists($out) ? $out : dirname($out))) {
407 throw new ServerException('Invalid or unwritable output file specified.');
408 }
409
410 if ($force || $this->needsCompile($out, $etag)) {
411 list($css, $etag) = $this->compile($in, $out);
412 } else {
413 $css = file_get_contents($out);
414 }
415
416 return $css;
417 }
418
419 /**
420 * Execute scssphp on a .scss file or a scssphp cache structure
421 *
422 * The scssphp cache structure contains information about a specific
423 * scss file having been parsed. It can be used as a hint for future
424 * calls to determine whether or not a rebuild is required.
425 *
426 * The cache structure contains two important keys that may be used
427 * externally:
428 *
429 * compiled: The final compiled CSS
430 * updated: The time (in seconds) the CSS was last compiled
431 *
432 * The cache structure is a plain-ol' PHP associative array and can
433 * be serialized and unserialized without a hitch.
434 *
435 * @param mixed $in Input
436 * @param boolean $force Force rebuild?
437 *
438 * @return array scssphp cache structure
439 */
440 public function cachedCompile($in, $force = false)
441 {
442 // assume no root
443 $root = null;
444
445 if (is_string($in)) {
446 $root = $in;
447 } elseif (is_array($in) and isset($in['root'])) {
448 if ($force or ! isset($in['files'])) {
449 // If we are forcing a recompile or if for some reason the
450 // structure does not contain any file information we should
451 // specify the root to trigger a rebuild.
452 $root = $in['root'];
453 } elseif (isset($in['files']) and is_array($in['files'])) {
454 foreach ($in['files'] as $fname => $ftime) {
455 if (! file_exists($fname) or filemtime($fname) > $ftime) {
456 // One of the files we knew about previously has changed
457 // so we should look at our incoming root again.
458 $root = $in['root'];
459 break;
460 }
461 }
462 }
463 } else {
464 // TODO: Throw an exception? We got neither a string nor something
465 // that looks like a compatible lessphp cache structure.
466 return null;
467 }
468
469 if ($root !== null) {
470 // If we have a root value which means we should rebuild.
471 $out = [];
472 $out['root'] = $root;
473 $out['compiled'] = $this->compileFile($root);
474 $out['files'] = $this->scss->getParsedFiles();
475 $out['updated'] = time();
476 return $out;
477 } else {
478 // No changes, pass back the structure
479 // we were given initially.
480 return $in;
481 }
482 }
483
484 /**
485 * Constructor
486 *
487 * @param string $dir Root directory to .scss files
488 * @param string $cacheDir Cache directory
489 * @param \Leafo\ScssPhp\Compiler|null $scss SCSS compiler instance
490 */
491 public function __construct($dir, $cacheDir = null, $scss = null)
492 {
493 $this->dir = $dir;
494
495 if (! isset($cacheDir)) {
496 $cacheDir = $this->join($dir, 'scss_cache');
497 }
498
499 $this->cacheDir = $cacheDir;
500
501 if (! is_dir($this->cacheDir)) {
502 throw new ServerException('Cache directory doesn\'t exist: ' . $cacheDir);
503 }
504
505 if (! isset($scss)) {
506 $scss = new Compiler();
507 $scss->setImportPaths($this->dir);
508 }
509
510 $this->scss = $scss;
511 $this->showErrorsAsCSS = false;
512
513 date_default_timezone_set('UTC');
514 }
515 }