3 namespace wcf\system\style
;
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
;
15 use wcf\util\FileUtil
;
17 use wcf\util\StringUtil
;
18 use wcf\util\StyleUtil
;
22 * Provides access to the SCSS PHP compiler.
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
29 final class StyleCompiler
extends SingletonFactory
32 * Contains all files, which are compiled for a style.
38 * names of option types which are supported as additional variables
41 public static $supportedOptionType = ['boolean', 'float', 'integer', 'radioButton', 'select'];
44 * file used to store global SCSS declarations, relative to `WCF_DIR`
47 const FILE_GLOBAL_VALUES
= 'style/ui/zzz_wsc_style_global_values.scss';
50 * registry keys for data storage
53 const REGISTRY_GLOBAL_VALUES
= 'styleGlobalValues';
55 public const SYSTEM_FONT_NAME
= 'system';
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';
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';
68 protected function init()
70 require_once(WCF_DIR
. 'lib/system/style/scssphp/scss.inc.php');
74 * Returns a fresh instance of the scssphp compiler.
76 protected function makeCompiler(): Compiler
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
]);
84 if (\ENABLE_DEBUG_MODE
&& \ENABLE_DEVELOPER_TOOLS
) {
85 $compiler->setOutputStyle(OutputStyle
::EXPANDED
);
87 $compiler->setOutputStyle(OutputStyle
::COMPRESSED
);
94 * Returns the default style variables as array.
99 public static function getDefaultVariables()
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');
110 // see https://github.com/WoltLab/WCF/issues/2636
111 if (empty($variables['wcfPageThemeColor'])) {
112 $variables['wcfPageThemeColor'] = $variables['wcfHeaderBackground'];
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 (!).
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
131 public function testStyle(
137 $customCustomSCSSFile = null
139 $individualScss = '';
140 if (isset($variables['individualScss'])) {
141 $individualScss = $variables['individualScss'];
142 unset($variables['individualScss']);
145 // add style image path
147 $imagePath = FileUtil
::getRelativePath(WCF_DIR
. 'style/', WCF_DIR
. $imagePath);
148 $imagePath = FileUtil
::addTrailingSlash(FileUtil
::unifyDirSeparator($imagePath));
150 $imagePath = '../images/';
152 $variables['style_image_path'] = "'{$imagePath}'";
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];
162 unset($variables['overrideScss']);
166 $variables['apiVersion'] = $apiVersion;
168 $parameters = ['scss' => ''];
169 EventHandler
::getInstance()->fireAction($this, 'compile', $parameters);
171 $files = $this->getFiles();
173 if ($customCustomSCSSFile !== null) {
174 if (($customSCSSFileKey = \array_search
(WCF_DIR
. self
::FILE_GLOBAL_VALUES
, $files)) !== false) {
175 unset($files[$customSCSSFileKey]);
178 $files[] = $customCustomSCSSFile;
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);
186 $scss .= $individualScss;
187 if (!empty($parameters['scss'])) {
188 $scss .= "\n" . $parameters['scss'];
192 $css = $this->compileStylesheet(
197 $this->writeCss(FileUtil
::addTrailingSlash($testFileDir) . 'style', $css);
198 } catch (\Exception
$e) {
206 * Returns a array with all files, which should be compiled for a style.
211 protected function getFiles()
214 $files = $this->getCoreFiles();
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 ?
222 $statement = WCF
::getDB()->prepareStatement($sql);
223 $statement->execute([
224 'style/([a-zA-Z0-9\-\.]+)\.scss',
227 while ($row = $statement->fetchArray()) {
228 // the global values will always be evaluated last
229 if ($row['filename'] === self
::FILE_GLOBAL_VALUES
) {
233 $files[] = Application
::getDirectory($row['application']) . $row['filename'];
237 if (\file_exists
(WCF_DIR
. self
::FILE_GLOBAL_VALUES
)) {
238 $files[] = WCF_DIR
. self
::FILE_GLOBAL_VALUES
;
241 $this->files
= $files;
248 * Compiles SCSS stylesheets.
250 * @param Style $style
252 public function compile(Style
$style)
254 // get style variables
255 $variables = $style->getVariables();
256 $individualScss = '';
257 if (isset($variables['individualScss'])) {
258 $individualScss = $variables['individualScss'];
259 unset($variables['individualScss']);
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));
268 $variables['style_image_path'] = "'{$imagePath}'";
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];
278 unset($variables['overrideScss']);
282 $variables['apiVersion'] = $style->apiVersion
;
284 $parameters = ['scss' => ''];
285 EventHandler
::getInstance()->fireAction($this, 'compile', $parameters);
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);
292 $scss .= $individualScss;
293 if (!empty($parameters['scss'])) {
294 $scss .= "\n" . $parameters['scss'];
297 $css = $this->compileStylesheet(
302 $preloadManifest = $this->buildPreloadManifest(
303 $this->extractPreloadRequests($css)
306 $this->writeCss($this->getFilenameForStyle($style), $css, $preloadManifest);
310 * Builds the preload manifest from the given iterable containing
313 * @see StyleCompiler::extractPreloadRequests()
316 private function buildPreloadManifest(iterable
$requests): array
318 $preloadManifest = ['http' => [], 'html' => []];
320 foreach ($requests as $request) {
321 if (Url
::is($request['filename'])) {
322 $filename = $request['filename'];
324 $filename = WCF
::getPath() . FileUtil
::getRealPath('style/' . $request['filename']);
327 $http = "<{$filename}>; rel=preload; as={$request['as']}";
329 '<link rel="preload" href="%s" as="%s"',
330 StringUtil
::encodeHTML($filename),
331 StringUtil
::encodeHTML($request['as'])
333 if ($request['crossorigin']) {
334 $http .= "; crossorigin";
335 $html .= " crossorigin";
337 if ($request['type']) {
338 $http .= \
sprintf('; type="%s"', \addslashes
($request['type']));
339 $html .= \
sprintf(' type="%s"', StringUtil
::encodeHTML($request['type']));
342 $preloadManifest['http'][] = $http;
343 $preloadManifest['html'][] = $html;
346 return $preloadManifest;
350 * Extracts preload requests from the given CSS string.
354 private function extractPreloadRequests(string $css): iterable
356 $regex = '/--woltlab-suite-preload:\\s*preload_dummy\\(((?:"(?:\\\\.|[^\\\\"])*"|[^")])+)\\)\\s*[;\\}]/';
357 if (!\
preg_match_all($regex, $css, $requests)) {
361 foreach ($requests[1] as $request) {
362 $regex = '/\s*("(?:\\\\.|[^\\\\"])*"|[^",]+)\s*(?:,|$)\s*/';
363 if (!\
preg_match_all($regex, $request, $parameters)) {
366 $parameters = $parameters[1];
367 if (\
count($parameters) < 4) {
370 $parameters = \array_map
(static function (string $parameter) {
371 if ($parameter[0] === '"') {
372 return \
stripslashes(\
substr($parameter, 1, -1));
377 [$filename, $as, $crossorigin, $type] = $parameters;
380 'filename' => $filename,
382 'crossorigin' => !!$crossorigin,
383 'type' => $type ?
: null,
389 * Compiles SCSS stylesheets for ACP usage.
391 public function compileACP()
393 $files = $this->getCoreFiles();
395 // ACP uses a slightly different layout
396 $files[] = WCF_DIR
. 'acp/style/layout.scss';
398 // include stylesheets from other apps in arbitrary order
400 foreach (ApplicationHandler
::getInstance()->getApplications() as $application) {
401 $files = \array_merge
($files, $this->getAcpStylesheets($application));
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();
412 while ($row = $statement->fetchArray()) {
413 $value = $row['defaultValue'];
418 $variables[$row['variableName']] = $value;
421 $variables['style_image_path'] = "'../images/'";
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);
429 $css = $this->compileStylesheet(
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);
439 $this->writeCss(WCF_DIR
. 'acp/style/style', $css);
443 * Returns a list of common stylesheets provided by the core.
445 * @return string[] list of common stylesheets
447 protected function getCoreFiles()
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)) {
456 $file = WCF_DIR
. "style/{$file}/";
457 if ($innerHandle = \
opendir($file)) {
458 while (($innerFile = \readdir
($innerHandle)) !== false) {
461 ||
$innerFile === '..'
462 ||
!\
is_file($file . $innerFile)
463 ||
!\
preg_match('~^[a-zA-Z0-9\-\.]+\.scss$~', $innerFile)
468 $files[] = $file . $innerFile;
470 \
closedir($innerHandle);
476 // directory order is not deterministic in some cases
484 * Returns the list of SCSS stylesheets of an application.
486 * @param Application $application
489 protected function getAcpStylesheets(Application
$application)
491 if ($application->packageID
== 1) {
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) {
509 * Reads in the SCSS files that form the foundation of the stylesheet. This includes
510 * the CSS reset and mixins.
512 protected function bootstrap(array $variables): string
514 // add reset like a boss
515 $content = $this->prepareFile(WCF_DIR
. 'style/bootstrap/reset.scss');
518 $content .= $this->prepareFile(WCF_DIR
. 'style/bootstrap/mixin.scss');
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);
526 @function preload($filename, $as, $crossorigin: false, $type: "") {
528 @return preload_dummy($filename, $as, 1, $type);
530 @return preload_dummy($filename, $as, 0, $type);
535 if (ApplicationHandler
::getInstance()->isMultiDomainSetup()) {
537 @function getFont($filename, $family: "/", $version: "") {
538 @return "../font/getFont.php?family=" + $family + "&filename=" + $filename + "&v=" + $version;
543 @function getFont($filename, $family: "/", $version: "") {
544 @if ($family != "") {
545 $family: "families/" + $family + "/";
547 @if ($version != "") {
548 $version: "?v=" + $version;
551 @return "../font/" + $family + $filename + $version;
556 if (!empty($variables['wcfFontFamilyGoogle'])) {
557 $content .= $this->getGoogleFontScss($variables['wcfFontFamilyGoogle']);
564 * Prepares a SCSS stylesheet for importing.
566 * @param string $filename
568 * @throws SystemException
570 protected function prepareFile($filename)
572 if (!\file_exists
($filename) ||
!\
is_readable($filename)) {
573 throw new SystemException("Unable to access '" . $filename . "', does not exist or is not readable");
576 // use a relative path
577 $filename = FileUtil
::getRelativePath(WCF_DIR
, \
dirname($filename)) . \basename
($filename);
579 return '@import "' . $filename . '";' . "\n";
583 * Compiles the given SCSS into one CSS stylesheet and returns it.
585 * @param string[] $variables
587 protected function compileStylesheet(string $scss, array $variables): string
589 foreach ($variables as &$value) {
590 if (StringUtil
::startsWith($value, '../')) {
591 $value = '~"' . $value . '"';
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'] . '"';
602 $variables['wcfFontFamily'] = $variables['wcfFontFamilyGoogle'] . ', ' . $variables['wcfFontFamily'];
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
;
609 if ($variables['wcfFontFamily'] === self
::SYSTEM_FONT_NAME
) {
610 $variables['wcfFontFamily'] = self
::SYSTEM_FONT_FAMILY
;
613 // add options as SCSS variables
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
. '"';
622 if (!isset($variables['apiVersion'])) {
623 $variables['apiVersion'] = Style
::API_VERSION
;
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"';
631 $variables['apiVersion'] = Style
::API_VERSION
;
634 // convert into numeric value for comparison, e.g. `3.1` -> `31`
635 $variables['apiVersion'] = \
str_replace('.', '', $variables['apiVersion']);
637 $compiler = $this->makeCompiler();
638 $compiler->setVariables($variables);
641 return $compiler->compile($scss);
642 } catch (\Exception
$e) {
643 throw new SystemException("Could not compile SCSS: " . $e->getMessage(), 0, '', $e);
648 * Converts the given CSS into the RTL variant.
650 * This method differs from StyleUtil::convertCSSToRTL() in that it includes some fixes
651 * for elements that need to remain LTR.
653 * @see StyleUtil::convertCSSToRTL()
655 private function convertToRtl(string $css): string
657 $css = StyleUtil
::convertCSSToRTL($css);
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";
670 * Writes the given css into the file with the given prefix.
672 private function writeCss(string $filePrefix, string $css, ?
array $preloadManifest = null): void
674 \file_put_contents
($filePrefix . '.css', $css);
675 FileUtil
::makeWritable($filePrefix . '.css');
677 \file_put_contents
($filePrefix . '-rtl.css', $this->convertToRtl($css));
678 FileUtil
::makeWritable($filePrefix . '-rtl.css');
680 if ($preloadManifest) {
681 \file_put_contents
($filePrefix . '-preload.json', JSON
::encode($preloadManifest));
682 FileUtil
::makeWritable($filePrefix . '-preload.json');
687 * Returns the SCSS required to load a Google font.
689 private function getGoogleFontScss(string $font): string
695 $cssFile = FontManager
::getInstance()->getCssFilename($font);
696 if (!\
is_readable($cssFile)) {
700 return \file_get_contents
($cssFile);
704 * Returns the name of the CSS file for a specific style.
706 * @param Style $style
710 public static function getFilenameForStyle(Style
$style)
712 return WCF_DIR
. 'style/style-' . $style->styleID
;