Merge branch '5.3'
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / style / StyleCompiler.class.php
1 <?php
2
3 namespace wcf\system\style;
4
5 use ScssPhp\ScssPhp\Compiler;
6 use ScssPhp\ScssPhp\OutputStyle;
7 use wcf\data\application\Application;
8 use wcf\data\option\Option;
9 use wcf\data\style\Style;
10 use wcf\system\application\ApplicationHandler;
11 use wcf\system\event\EventHandler;
12 use wcf\system\exception\SystemException;
13 use wcf\system\SingletonFactory;
14 use wcf\system\WCF;
15 use wcf\util\FileUtil;
16 use wcf\util\JSON;
17 use wcf\util\StringUtil;
18 use wcf\util\StyleUtil;
19 use wcf\util\Url;
20
21 /**
22 * Provides access to the SCSS PHP compiler.
23 *
24 * @author Tim Duesterhus, Alexander Ebert
25 * @copyright 2001-2021 WoltLab GmbH
26 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
27 * @package WoltLabSuite\Core\System\Style
28 */
29 final class StyleCompiler extends SingletonFactory
30 {
31 /**
32 * Contains all files, which are compiled for a style.
33 * @var string[]
34 */
35 protected $files;
36
37 /**
38 * names of option types which are supported as additional variables
39 * @var string[]
40 */
41 public static $supportedOptionType = ['boolean', 'float', 'integer', 'radioButton', 'select'];
42
43 /**
44 * file used to store global SCSS declarations, relative to `WCF_DIR`
45 * @var string
46 */
47 const FILE_GLOBAL_VALUES = 'style/ui/zzz_wsc_style_global_values.scss';
48
49 /**
50 * registry keys for data storage
51 * @var string
52 */
53 const REGISTRY_GLOBAL_VALUES = 'styleGlobalValues';
54
55 public const SYSTEM_FONT_NAME = 'system';
56
57 private const SYSTEM_FONT_FAMILY = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
58 "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
59 "Helvetica Neue", Arial, sans-serif';
60
61 private const SYSTEM_FONT_FAMILY_MONOSPACE = 'ui-monospace, Menlo, Monaco, "Cascadia Mono",
62 "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
63 "Fira Mono", "Droid Sans Mono", "Courier New", monospace';
64
65 /**
66 * @inheritDoc
67 */
68 protected function init()
69 {
70 require_once(WCF_DIR . 'lib/system/style/scssphp/scss.inc.php');
71 }
72
73 /**
74 * Returns a fresh instance of the scssphp compiler.
75 */
76 protected function makeCompiler(): Compiler
77 {
78 $compiler = new Compiler();
79 // Disable Unicode support because of its horrible performance (7x slowdown)
80 // https://github.com/WoltLab/WCF/pull/2736#issuecomment-416084079
81 $compiler->setEncoding('iso8859-1');
82 $compiler->setImportPaths([WCF_DIR]);
83
84 if (\ENABLE_DEBUG_MODE && \ENABLE_DEVELOPER_TOOLS) {
85 $compiler->setOutputStyle(OutputStyle::EXPANDED);
86 } else {
87 $compiler->setOutputStyle(OutputStyle::COMPRESSED);
88 }
89
90 return $compiler;
91 }
92
93 /**
94 * Returns the default style variables as array.
95 *
96 * @return string[]
97 * @since 5.3
98 */
99 public static function getDefaultVariables()
100 {
101 $variables = [];
102
103 $sql = "SELECT variable.variableName, variable.defaultValue
104 FROM wcf" . WCF_N . "_style_variable variable
105 ORDER BY variable.variableID ASC";
106 $statement = WCF::getDB()->prepareStatement($sql);
107 $statement->execute();
108 $variables = $statement->fetchMap('variableName', 'defaultValue');
109
110 // see https://github.com/WoltLab/WCF/issues/2636
111 if (empty($variables['wcfPageThemeColor'])) {
112 $variables['wcfPageThemeColor'] = $variables['wcfHeaderBackground'];
113 }
114
115 return $variables;
116 }
117
118 /**
119 * Test a style with the given apiVersion, imagePath and variables. If the style is valid and does not throw an
120 * error, null is returned. Otherwise the exception is returned (!).
121 *
122 * @param string $testFileDir
123 * @param string $styleName
124 * @param string $apiVersion
125 * @param string $imagePath
126 * @param string[] $variables
127 * @param string|null $customCustomSCSSFile
128 * @return null|\Exception
129 * @since 5.3
130 */
131 public function testStyle(
132 $testFileDir,
133 $styleName,
134 $apiVersion,
135 $imagePath,
136 array $variables,
137 $customCustomSCSSFile = null
138 ) {
139 $individualScss = '';
140 if (isset($variables['individualScss'])) {
141 $individualScss = $variables['individualScss'];
142 unset($variables['individualScss']);
143 }
144
145 // add style image path
146 if ($imagePath) {
147 $imagePath = FileUtil::getRelativePath(WCF_DIR . 'style/', WCF_DIR . $imagePath);
148 $imagePath = FileUtil::addTrailingSlash(FileUtil::unifyDirSeparator($imagePath));
149 } else {
150 $imagePath = '../images/';
151 }
152 $variables['style_image_path'] = "'{$imagePath}'";
153
154 // apply overrides
155 if (isset($variables['overrideScss'])) {
156 $lines = \explode("\n", StringUtil::unifyNewlines($variables['overrideScss']));
157 foreach ($lines as $line) {
158 if (\preg_match('~^@([a-zA-Z]+): ?([@a-zA-Z0-9 ,\.\(\)\%\#-]+);$~', $line, $matches)) {
159 $variables[$matches[1]] = $matches[2];
160 }
161 }
162 unset($variables['overrideScss']);
163 }
164
165 // api version
166 $variables['apiVersion'] = $apiVersion;
167
168 $parameters = ['scss' => ''];
169 EventHandler::getInstance()->fireAction($this, 'compile', $parameters);
170
171 $files = $this->getFiles();
172
173 if ($customCustomSCSSFile !== null) {
174 if (($customSCSSFileKey = \array_search(WCF_DIR . self::FILE_GLOBAL_VALUES, $files)) !== false) {
175 unset($files[$customSCSSFileKey]);
176 }
177
178 $files[] = $customCustomSCSSFile;
179 }
180
181 $scss = "/*!\n\nstylesheet for '" . $styleName . "', generated on " . \gmdate('r') . " -- DO NOT EDIT\n\n*/\n";
182 $scss .= $this->bootstrap($variables);
183 foreach ($files as $file) {
184 $scss .= $this->prepareFile($file);
185 }
186 $scss .= $individualScss;
187 if (!empty($parameters['scss'])) {
188 $scss .= "\n" . $parameters['scss'];
189 }
190
191 try {
192 $css = $this->compileStylesheet(
193 $scss,
194 $variables
195 );
196
197 $this->writeCss(FileUtil::addTrailingSlash($testFileDir) . 'style', $css);
198 } catch (\Exception $e) {
199 return $e;
200 }
201
202 return null;
203 }
204
205 /**
206 * Returns a array with all files, which should be compiled for a style.
207 *
208 * @return string[]
209 * @since 5.3
210 */
211 protected function getFiles()
212 {
213 if (!$this->files) {
214 $files = $this->getCoreFiles();
215
216 // read stylesheets in dependency order
217 $sql = "SELECT filename, application
218 FROM wcf" . WCF_N . "_package_installation_file_log
219 WHERE CONVERT(filename using utf8) REGEXP ?
220 AND packageID <> ?
221 ORDER BY packageID";
222 $statement = WCF::getDB()->prepareStatement($sql);
223 $statement->execute([
224 'style/([a-zA-Z0-9\-\.]+)\.scss',
225 1,
226 ]);
227 while ($row = $statement->fetchArray()) {
228 // the global values will always be evaluated last
229 if ($row['filename'] === self::FILE_GLOBAL_VALUES) {
230 continue;
231 }
232
233 $files[] = Application::getDirectory($row['application']) . $row['filename'];
234 }
235
236 // global SCSS
237 if (\file_exists(WCF_DIR . self::FILE_GLOBAL_VALUES)) {
238 $files[] = WCF_DIR . self::FILE_GLOBAL_VALUES;
239 }
240
241 $this->files = $files;
242 }
243
244 return $this->files;
245 }
246
247 /**
248 * Compiles SCSS stylesheets.
249 *
250 * @param Style $style
251 */
252 public function compile(Style $style)
253 {
254 // get style variables
255 $variables = $style->getVariables();
256 $individualScss = '';
257 if (isset($variables['individualScss'])) {
258 $individualScss = $variables['individualScss'];
259 unset($variables['individualScss']);
260 }
261
262 // add style image path
263 $imagePath = '../images/';
264 if ($style->imagePath) {
265 $imagePath = FileUtil::getRelativePath(WCF_DIR . 'style/', WCF_DIR . $style->imagePath);
266 $imagePath = FileUtil::addTrailingSlash(FileUtil::unifyDirSeparator($imagePath));
267 }
268 $variables['style_image_path'] = "'{$imagePath}'";
269
270 // apply overrides
271 if (isset($variables['overrideScss'])) {
272 $lines = \explode("\n", StringUtil::unifyNewlines($variables['overrideScss']));
273 foreach ($lines as $line) {
274 if (\preg_match('~^@([a-zA-Z]+): ?([@a-zA-Z0-9 ,\.\(\)\%\#-]+);$~', $line, $matches)) {
275 $variables[$matches[1]] = $matches[2];
276 }
277 }
278 unset($variables['overrideScss']);
279 }
280
281 // api version
282 $variables['apiVersion'] = $style->apiVersion;
283
284 $parameters = ['scss' => ''];
285 EventHandler::getInstance()->fireAction($this, 'compile', $parameters);
286
287 $scss = "/*!\n\nstylesheet for '" . $style->styleName . "', generated on " . \gmdate('r') . " -- DO NOT EDIT\n\n*/\n";
288 $scss .= $this->bootstrap($variables);
289 foreach ($this->getFiles() as $file) {
290 $scss .= $this->prepareFile($file);
291 }
292 $scss .= $individualScss;
293 if (!empty($parameters['scss'])) {
294 $scss .= "\n" . $parameters['scss'];
295 }
296
297 $css = $this->compileStylesheet(
298 $scss,
299 $variables
300 );
301
302 $preloadManifest = $this->buildPreloadManifest(
303 $this->extractPreloadRequests($css)
304 );
305
306 $this->writeCss($this->getFilenameForStyle($style), $css, $preloadManifest);
307 }
308
309 /**
310 * Builds the preload manifest from the given iterable containing
311 * preload requests.
312 *
313 * @see StyleCompiler::extractPreloadRequests()
314 * @since 5.4
315 */
316 private function buildPreloadManifest(iterable $requests): array
317 {
318 $preloadManifest = ['http' => [], 'html' => []];
319
320 foreach ($requests as $request) {
321 if (Url::is($request['filename'])) {
322 $filename = $request['filename'];
323 } else {
324 $filename = WCF::getPath() . FileUtil::getRealPath('style/' . $request['filename']);
325 }
326
327 $http = "<{$filename}>; rel=preload; as={$request['as']}";
328 $html = \sprintf(
329 '<link rel="preload" href="%s" as="%s"',
330 StringUtil::encodeHTML($filename),
331 StringUtil::encodeHTML($request['as'])
332 );
333 if ($request['crossorigin']) {
334 $http .= "; crossorigin";
335 $html .= " crossorigin";
336 }
337 if ($request['type']) {
338 $http .= \sprintf('; type="%s"', \addslashes($request['type']));
339 $html .= \sprintf(' type="%s"', StringUtil::encodeHTML($request['type']));
340 }
341 $html .= '>';
342 $preloadManifest['http'][] = $http;
343 $preloadManifest['html'][] = $html;
344 }
345
346 return $preloadManifest;
347 }
348
349 /**
350 * Extracts preload requests from the given CSS string.
351 *
352 * @since 5.4
353 */
354 private function extractPreloadRequests(string $css): iterable
355 {
356 $regex = '/--woltlab-suite-preload:\\s*preload_dummy\\(((?:"(?:\\\\.|[^\\\\"])*"|[^")])+)\\)\\s*[;\\}]/';
357 if (!\preg_match_all($regex, $css, $requests)) {
358 return [];
359 }
360
361 foreach ($requests[1] as $request) {
362 $regex = '/\s*("(?:\\\\.|[^\\\\"])*"|[^",]+)\s*(?:,|$)\s*/';
363 if (!\preg_match_all($regex, $request, $parameters)) {
364 continue;
365 }
366 $parameters = $parameters[1];
367 if (\count($parameters) < 4) {
368 continue;
369 }
370 $parameters = \array_map(static function (string $parameter) {
371 if ($parameter[0] === '"') {
372 return \stripslashes(\substr($parameter, 1, -1));
373 }
374
375 return $parameter;
376 }, $parameters);
377 [$filename, $as, $crossorigin, $type] = $parameters;
378
379 yield [
380 'filename' => $filename,
381 'as' => $as,
382 'crossorigin' => !!$crossorigin,
383 'type' => $type ?: null,
384 ];
385 }
386 }
387
388 /**
389 * Compiles SCSS stylesheets for ACP usage.
390 */
391 public function compileACP()
392 {
393 $files = $this->getCoreFiles();
394
395 // ACP uses a slightly different layout
396 $files[] = WCF_DIR . 'acp/style/layout.scss';
397
398 // include stylesheets from other apps in arbitrary order
399 if (PACKAGE_ID) {
400 foreach (ApplicationHandler::getInstance()->getApplications() as $application) {
401 $files = \array_merge($files, $this->getAcpStylesheets($application));
402 }
403 }
404
405 // read default values
406 $sql = "SELECT variableName, defaultValue
407 FROM wcf" . WCF_N . "_style_variable
408 ORDER BY variableID ASC";
409 $statement = WCF::getDB()->prepareStatement($sql);
410 $statement->execute();
411 $variables = [];
412 while ($row = $statement->fetchArray()) {
413 $value = $row['defaultValue'];
414 if (empty($value)) {
415 $value = '~""';
416 }
417
418 $variables[$row['variableName']] = $value;
419 }
420
421 $variables['style_image_path'] = "'../images/'";
422
423 $scss = "/*!\n\nstylesheet for the admin panel, generated on " . \gmdate('r') . " -- DO NOT EDIT\n\n*/\n";
424 $scss .= $this->bootstrap($variables);
425 foreach ($files as $file) {
426 $scss .= $this->prepareFile($file);
427 }
428
429 $css = $this->compileStylesheet(
430 $scss,
431 $variables
432 );
433
434 // fix relative paths
435 $css = \str_replace('../font/', '../../font/', $css);
436 $css = \str_replace('../icon/', '../../icon/', $css);
437 $css = \preg_replace('~\.\./images/~', '../../images/', $css);
438
439 $this->writeCss(WCF_DIR . 'acp/style/style', $css);
440 }
441
442 /**
443 * Returns a list of common stylesheets provided by the core.
444 *
445 * @return string[] list of common stylesheets
446 */
447 protected function getCoreFiles()
448 {
449 $files = [];
450 if ($handle = \opendir(WCF_DIR . 'style/')) {
451 while (($file = \readdir($handle)) !== false) {
452 if ($file === '.' || $file === '..' || $file === 'bootstrap' || \is_file(WCF_DIR . 'style/' . $file)) {
453 continue;
454 }
455
456 $file = WCF_DIR . "style/{$file}/";
457 if ($innerHandle = \opendir($file)) {
458 while (($innerFile = \readdir($innerHandle)) !== false) {
459 if (
460 $innerFile === '.'
461 || $innerFile === '..'
462 || !\is_file($file . $innerFile)
463 || !\preg_match('~^[a-zA-Z0-9\-\.]+\.scss$~', $innerFile)
464 ) {
465 continue;
466 }
467
468 $files[] = $file . $innerFile;
469 }
470 \closedir($innerHandle);
471 }
472 }
473
474 \closedir($handle);
475
476 // directory order is not deterministic in some cases
477 \sort($files);
478 }
479
480 return $files;
481 }
482
483 /**
484 * Returns the list of SCSS stylesheets of an application.
485 *
486 * @param Application $application
487 * @return string[]
488 */
489 protected function getAcpStylesheets(Application $application)
490 {
491 if ($application->packageID == 1) {
492 return [];
493 }
494
495 $files = [];
496
497 $basePath = FileUtil::addTrailingSlash(FileUtil::getRealPath(WCF_DIR . $application->getPackage()->packageDir)) . 'acp/style/';
498 $result = \glob($basePath . '*.scss');
499 if (\is_array($result)) {
500 foreach ($result as $file) {
501 $files[] = $file;
502 }
503 }
504
505 return $files;
506 }
507
508 /**
509 * Reads in the SCSS files that form the foundation of the stylesheet. This includes
510 * the CSS reset and mixins.
511 */
512 protected function bootstrap(array $variables): string
513 {
514 // add reset like a boss
515 $content = $this->prepareFile(WCF_DIR . 'style/bootstrap/reset.scss');
516
517 // add mixins
518 $content .= $this->prepareFile(WCF_DIR . 'style/bootstrap/mixin.scss');
519
520 // add newer mixins added with version 3.0
521 foreach (\glob(WCF_DIR . 'style/bootstrap/mixin/*.scss') as $mixin) {
522 $content .= $this->prepareFile($mixin);
523 }
524
525 $content .= <<<'EOT'
526 @function preload($filename, $as, $crossorigin: false, $type: "") {
527 @if $crossorigin {
528 @return preload_dummy($filename, $as, 1, $type);
529 } @else {
530 @return preload_dummy($filename, $as, 0, $type);
531 }
532 }
533 EOT;
534
535 if (ApplicationHandler::getInstance()->isMultiDomainSetup()) {
536 $content .= <<<'EOT'
537 @function getFont($filename, $family: "/", $version: "") {
538 @return "../font/getFont.php?family=" + $family + "&filename=" + $filename + "&v=" + $version;
539 }
540 EOT;
541 } else {
542 $content .= <<<'EOT'
543 @function getFont($filename, $family: "/", $version: "") {
544 @if ($family != "") {
545 $family: "families/" + $family + "/";
546 }
547 @if ($version != "") {
548 $version: "?v=" + $version;
549 }
550
551 @return "../font/" + $family + $filename + $version;
552 }
553 EOT;
554 }
555
556 if (!empty($variables['wcfFontFamilyGoogle'])) {
557 $content .= $this->getGoogleFontScss($variables['wcfFontFamilyGoogle']);
558 }
559
560 return $content;
561 }
562
563 /**
564 * Prepares a SCSS stylesheet for importing.
565 *
566 * @param string $filename
567 * @return string
568 * @throws SystemException
569 */
570 protected function prepareFile($filename)
571 {
572 if (!\file_exists($filename) || !\is_readable($filename)) {
573 throw new SystemException("Unable to access '" . $filename . "', does not exist or is not readable");
574 }
575
576 // use a relative path
577 $filename = FileUtil::getRelativePath(WCF_DIR, \dirname($filename)) . \basename($filename);
578
579 return '@import "' . $filename . '";' . "\n";
580 }
581
582 /**
583 * Compiles the given SCSS into one CSS stylesheet and returns it.
584 *
585 * @param string[] $variables
586 */
587 protected function compileStylesheet(string $scss, array $variables): string
588 {
589 foreach ($variables as &$value) {
590 if (StringUtil::startsWith($value, '../')) {
591 $value = '~"' . $value . '"';
592 }
593 }
594 unset($value);
595
596 $variables['wcfFontFamily'] = $variables['wcfFontFamilyFallback'];
597 if (!empty($variables['wcfFontFamilyGoogle']) && $variables['wcfFontFamilyGoogle'] !== '~""') {
598 // The SCSS parser attempts to evaluate the variables, causing issues with font names that
599 // include logical operators such as "And" or "Or".
600 $variables['wcfFontFamilyGoogle'] = '"' . $variables['wcfFontFamilyGoogle'] . '"';
601
602 $variables['wcfFontFamily'] = $variables['wcfFontFamilyGoogle'] . ', ' . $variables['wcfFontFamily'];
603 }
604
605 // Define the font family set for the OS default fonts. This needs to be happen statically to
606 // allow modifications in the future in case of changes.
607 $variables['wcfFontFamilyMonospace'] = self::SYSTEM_FONT_FAMILY_MONOSPACE;
608
609 if ($variables['wcfFontFamily'] === self::SYSTEM_FONT_NAME) {
610 $variables['wcfFontFamily'] = self::SYSTEM_FONT_FAMILY;
611 }
612
613 // add options as SCSS variables
614 if (PACKAGE_ID) {
615 foreach (Option::getOptions() as $constantName => $option) {
616 if (\in_array($option->optionType, static::$supportedOptionType)) {
617 $variables['wcf_option_' . \mb_strtolower($constantName)] = \is_int($option->optionValue) ? $option->optionValue : '"' . $option->optionValue . '"';
618 }
619 }
620
621 // api version
622 if (!isset($variables['apiVersion'])) {
623 $variables['apiVersion'] = Style::API_VERSION;
624 }
625 } else {
626 // workaround during setup
627 $variables['wcf_option_attachment_thumbnail_height'] = '~"210"';
628 $variables['wcf_option_attachment_thumbnail_width'] = '~"280"';
629 $variables['wcf_option_signature_max_image_height'] = '~"150"';
630
631 $variables['apiVersion'] = Style::API_VERSION;
632 }
633
634 // convert into numeric value for comparison, e.g. `3.1` -> `31`
635 $variables['apiVersion'] = \str_replace('.', '', $variables['apiVersion']);
636
637 $compiler = $this->makeCompiler();
638 $compiler->setVariables($variables);
639
640 try {
641 return $compiler->compile($scss);
642 } catch (\Exception $e) {
643 throw new SystemException("Could not compile SCSS: " . $e->getMessage(), 0, '', $e);
644 }
645 }
646
647 /**
648 * Converts the given CSS into the RTL variant.
649 *
650 * This method differs from StyleUtil::convertCSSToRTL() in that it includes some fixes
651 * for elements that need to remain LTR.
652 *
653 * @see StyleUtil::convertCSSToRTL()
654 */
655 private function convertToRtl(string $css): string
656 {
657 $css = StyleUtil::convertCSSToRTL($css);
658
659 // force code boxes to be always LTR
660 $css .= "\n/* RTL fix for code boxes */\n";
661 $css .= ".redactor-layer pre { direction: ltr; text-align: left; }\n";
662 $css .= ".codeBoxCode { direction: ltr; } \n";
663 $css .= ".codeBox .codeBoxCode { padding-left: 7ch; padding-right: 0; } \n";
664 $css .= ".codeBox .codeBoxCode > code .codeBoxLine > a { margin-left: -7ch; margin-right: 0; text-align: right; } \n";
665
666 return $css;
667 }
668
669 /**
670 * Writes the given css into the file with the given prefix.
671 */
672 private function writeCss(string $filePrefix, string $css, ?array $preloadManifest = null): void
673 {
674 \file_put_contents($filePrefix . '.css', $css);
675 FileUtil::makeWritable($filePrefix . '.css');
676
677 \file_put_contents($filePrefix . '-rtl.css', $this->convertToRtl($css));
678 FileUtil::makeWritable($filePrefix . '-rtl.css');
679
680 if ($preloadManifest) {
681 \file_put_contents($filePrefix . '-preload.json', JSON::encode($preloadManifest));
682 FileUtil::makeWritable($filePrefix . '-preload.json');
683 }
684 }
685
686 /**
687 * Returns the SCSS required to load a Google font.
688 */
689 private function getGoogleFontScss(string $font): string
690 {
691 if (!PACKAGE_ID) {
692 return '';
693 }
694
695 $cssFile = FontManager::getInstance()->getCssFilename($font);
696 if (!\is_readable($cssFile)) {
697 return '';
698 }
699
700 return \file_get_contents($cssFile);
701 }
702
703 /**
704 * Returns the name of the CSS file for a specific style.
705 *
706 * @param Style $style
707 * @return string
708 * @since 5.3
709 */
710 public static function getFilenameForStyle(Style $style)
711 {
712 return WCF_DIR . 'style/style-' . $style->styleID;
713 }
714 }