2 namespace wcf\system\style
;
3 use ScssPhp\ScssPhp\Compiler
;
4 use ScssPhp\ScssPhp\Formatter\Crunched
as CrunchedFormatter
;
5 use wcf\data\application\Application
;
6 use wcf\data\option\Option
;
7 use wcf\data\style\Style
;
8 use wcf\system\application\ApplicationHandler
;
9 use wcf\system\event\EventHandler
;
10 use wcf\system\exception\SystemException
;
11 use wcf\system\SingletonFactory
;
13 use wcf\util\FileUtil
;
14 use wcf\util\StringUtil
;
15 use wcf\util\StyleUtil
;
18 * Provides access to the SCSS PHP compiler.
20 * @author Alexander Ebert
21 * @copyright 2001-2020 WoltLab GmbH
22 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
23 * @package WoltLabSuite\Core\System\Style
25 class StyleCompiler
extends SingletonFactory
{
27 * SCSS compiler object
30 protected $compiler = null;
33 * Contains all files, which are compiled for a style.
39 * names of option types which are supported as additional variables
42 public static $supportedOptionType = ['boolean', 'float', 'integer', 'radioButton', 'select'];
45 * file used to store global SCSS declarations, relative to `WCF_DIR`
48 const FILE_GLOBAL_VALUES
= 'style/ui/zzz_wsc_style_global_values.scss';
51 * registry keys for data storage
54 const REGISTRY_GLOBAL_VALUES
= 'styleGlobalValues';
59 protected function init() {
60 require_once(WCF_DIR
.'lib/system/style/scssphp/scss.inc.php');
61 $this->compiler
= new Compiler();
62 // Disable Unicode support because of its horrible performance (7x slowdown)
63 // https://github.com/WoltLab/WCF/pull/2736#issuecomment-416084079
64 $this->compiler
->setEncoding('iso8859-1');
65 $this->compiler
->setImportPaths([WCF_DIR
]);
69 * Returns the default style variables as array.
74 public static function getDefaultVariables() {
77 $sql = "SELECT variable.variableName, variable.defaultValue
78 FROM wcf".WCF_N
."_style_variable variable
79 ORDER BY variable.variableID ASC";
80 $statement = WCF
::getDB()->prepareStatement($sql);
81 $statement->execute();
82 $variables = $statement->fetchMap('variableName', 'defaultValue');
84 // see https://github.com/WoltLab/WCF/issues/2636
85 if (empty($variables['wcfPageThemeColor'])) {
86 $variables['wcfPageThemeColor'] = $variables['wcfHeaderBackground'];
93 * Test a style with the given apiVersion, imagePath and variables. If the style is valid and does not throw an
94 * error, null is returned. Otherwise the exception is returned (!).
96 * @param string $testFileDir
97 * @param string $styleName
98 * @param string $apiVersion
99 * @param string $imagePath
100 * @param string[] $variables
101 * @param string|null $customCustomSCSSFile
102 * @return null|\Exception
105 public function testStyle($testFileDir, $styleName, $apiVersion, $imagePath, array $variables, $customCustomSCSSFile = null) {
106 $individualScss = '';
107 if (isset($variables['individualScss'])) {
108 $individualScss = $variables['individualScss'];
109 unset($variables['individualScss']);
112 // add style image path
114 $imagePath = FileUtil
::getRelativePath(WCF_DIR
. 'style/', WCF_DIR
. $imagePath);
115 $imagePath = FileUtil
::addTrailingSlash(FileUtil
::unifyDirSeparator($imagePath));
118 $imagePath = '../images/';
120 $variables['style_image_path'] = "'{$imagePath}'";
123 if (isset($variables['overrideScss'])) {
124 $lines = explode("\n", StringUtil
::unifyNewlines($variables['overrideScss']));
125 foreach ($lines as $line) {
126 if (preg_match('~^@([a-zA-Z]+): ?([@a-zA-Z0-9 ,\.\(\)\%\#-]+);$~', $line, $matches)) {
127 $variables[$matches[1]] = $matches[2];
130 unset($variables['overrideScss']);
134 $variables['apiVersion'] = $apiVersion;
136 $parameters = ['scss' => ''];
137 EventHandler
::getInstance()->fireAction($this, 'compile', $parameters);
139 $files = $this->getFiles();
141 if ($customCustomSCSSFile !== null) {
142 if (($customSCSSFileKey = array_search(WCF_DIR
. self
::FILE_GLOBAL_VALUES
, $files)) !== false) {
143 unset($files[$customSCSSFileKey]);
146 $files[] = $customCustomSCSSFile;
150 $this->compileStylesheet(
151 FileUtil
::addTrailingSlash($testFileDir) . 'style',
154 $individualScss . (!empty($parameters['scss']) ?
"\n" . $parameters['scss'] : ''),
155 function($content) use ($styleName) {
156 $header = "/* stylesheet for '".$styleName."', generated on ".gmdate('r')." -- DO NOT EDIT */";
157 return '@charset "UTF-8";' . "\n\n{$header}\n\n" . preg_replace('~^@charset "UTF-8";\r?\n~', '', $content);
161 catch (\Exception
$e) {
169 * Returns a array with all files, which should be compiled for a style.
174 protected function getFiles() {
176 $files = $this->getCoreFiles();
178 // read stylesheets in dependency order
179 $sql = "SELECT filename, application
180 FROM wcf".WCF_N
."_package_installation_file_log
181 WHERE CONVERT(filename using utf8) REGEXP ?
184 $statement = WCF
::getDB()->prepareStatement($sql);
185 $statement->execute([
186 'style/([a-zA-Z0-9\-\.]+)\.scss',
189 while ($row = $statement->fetchArray()) {
190 // the global values will always be evaluated last
191 if ($row['filename'] === self
::FILE_GLOBAL_VALUES
) {
195 $files[] = Application
::getDirectory($row['application']).$row['filename'];
199 if (file_exists(WCF_DIR
. self
::FILE_GLOBAL_VALUES
)) {
200 $files[] = WCF_DIR
. self
::FILE_GLOBAL_VALUES
;
203 $this->files
= $files;
210 * Compiles SCSS stylesheets.
212 * @param Style $style
214 public function compile(Style
$style) {
215 // get style variables
216 $variables = $style->getVariables();
217 $individualScss = '';
218 if (isset($variables['individualScss'])) {
219 $individualScss = $variables['individualScss'];
220 unset($variables['individualScss']);
223 // add style image path
224 $imagePath = '../images/';
225 if ($style->imagePath
) {
226 $imagePath = FileUtil
::getRelativePath(WCF_DIR
. 'style/', WCF_DIR
. $style->imagePath
);
227 $imagePath = FileUtil
::addTrailingSlash(FileUtil
::unifyDirSeparator($imagePath));
229 $variables['style_image_path'] = "'{$imagePath}'";
232 if (isset($variables['overrideScss'])) {
233 $lines = explode("\n", StringUtil
::unifyNewlines($variables['overrideScss']));
234 foreach ($lines as $line) {
235 if (preg_match('~^@([a-zA-Z]+): ?([@a-zA-Z0-9 ,\.\(\)\%\#-]+);$~', $line, $matches)) {
236 $variables[$matches[1]] = $matches[2];
239 unset($variables['overrideScss']);
243 $variables['apiVersion'] = $style->apiVersion
;
245 $parameters = ['scss' => ''];
246 EventHandler
::getInstance()->fireAction($this, 'compile', $parameters);
248 $this->compileStylesheet(
249 $this->getFilenameForStyle($style),
252 $individualScss . (!empty($parameters['scss']) ?
"\n" . $parameters['scss'] : ''),
253 function($content) use ($style) {
254 $header = "/* stylesheet for '".$style->styleName
."', generated on ".gmdate('r')." -- DO NOT EDIT */";
255 return '@charset "UTF-8";' . "\n\n{$header}\n\n" . preg_replace('~^@charset "UTF-8";\r?\n~', '', $content);
261 * Compiles SCSS stylesheets for ACP usage.
263 public function compileACP() {
264 $files = $this->getCoreFiles();
266 // ACP uses a slightly different layout
267 $files[] = WCF_DIR
. 'acp/style/layout.scss';
269 // include stylesheets from other apps in arbitrary order
271 foreach (ApplicationHandler
::getInstance()->getApplications() as $application) {
272 $files = array_merge($files, $this->getAcpStylesheets($application));
276 // read default values
277 $sql = "SELECT variableName, defaultValue
278 FROM wcf".WCF_N
."_style_variable
279 ORDER BY variableID ASC";
280 $statement = WCF
::getDB()->prepareStatement($sql);
281 $statement->execute();
283 while ($row = $statement->fetchArray()) {
284 $value = $row['defaultValue'];
289 $variables[$row['variableName']] = $value;
292 $variables['wcfFontFamily'] = $variables['wcfFontFamilyFallback'];
293 if (!empty($variables['wcfFontFamilyGoogle'])) {
294 $variables['wcfFontFamily'] = '"' . $variables['wcfFontFamilyGoogle'] . '", ' . $variables['wcfFontFamily'];
297 $variables['style_image_path'] = "'../images/'";
299 $this->compileStylesheet(
300 WCF_DIR
.'acp/style/style',
305 // fix relative paths
306 $content = str_replace('../font/', '../../font/', $content);
307 $content = str_replace('../icon/', '../../icon/', $content);
308 $content = preg_replace('~\.\./images/~', '../../images/', $content);
310 $header = "/* stylesheet for the admin panel, generated on ".gmdate('r')." -- DO NOT EDIT */";
311 return '@charset "UTF-8";' . "\n\n{$header}\n\n" . preg_replace('~^@charset "UTF-8";\r?\n~', '', $content);
317 * Returns a list of common stylesheets provided by the core.
319 * @return string[] list of common stylesheets
321 protected function getCoreFiles() {
323 if ($handle = opendir(WCF_DIR
.'style/')) {
324 while (($file = readdir($handle)) !== false) {
325 if ($file === '.' ||
$file === '..' ||
$file === 'bootstrap' ||
is_file(WCF_DIR
.'style/'.$file)) {
329 $file = WCF_DIR
."style/{$file}/";
330 if ($innerHandle = opendir($file)) {
331 while (($innerFile = readdir($innerHandle)) !== false) {
332 if ($innerFile === '.' ||
$innerFile === '..' ||
!is_file($file.$innerFile) ||
!preg_match('~^[a-zA-Z0-9\-\.]+\.scss$~', $innerFile)) {
336 $files[] = $file.$innerFile;
338 closedir($innerHandle);
344 // directory order is not deterministic in some cases
352 * Returns the list of SCSS stylesheets of an application.
354 * @param Application $application
357 protected function getAcpStylesheets(Application
$application) {
358 if ($application->packageID
== 1) return [];
362 $basePath = FileUtil
::addTrailingSlash(FileUtil
::getRealPath(WCF_DIR
. $application->getPackage()->packageDir
)) . 'acp/style/';
363 $result = glob($basePath . '*.scss');
364 if (is_array($result)) {
365 foreach ($result as $file) {
374 * Prepares the style compiler by adding variables to environment.
376 * @param string[] $variables
379 protected function bootstrap(array $variables) {
380 // add reset like a boss
381 $content = $this->prepareFile(WCF_DIR
.'style/bootstrap/reset.scss');
383 // apply style variables
384 $this->compiler
->setVariables($variables);
387 $content .= $this->prepareFile(WCF_DIR
.'style/bootstrap/mixin.scss');
389 // add newer mixins added with version 3.0
390 foreach (glob(WCF_DIR
.'style/bootstrap/mixin/*.scss') as $mixin) {
391 $content .= $this->prepareFile($mixin);
394 if (ApplicationHandler
::getInstance()->isMultiDomainSetup()) {
396 @function getFont($filename, $family: "/", $version: "") {
397 @return "../font/getFont.php?family=" + $family + "&filename=" + $filename + "&v=" + $version;
403 @function getFont($filename, $family: "/", $version: "") {
404 @if ($family != "") {
405 $family: "families/" + $family + "/";
407 @if ($version != "") {
408 $version: "?v=" + $version;
411 @return "../font/" + $family + $filename + $version;
417 if (!empty($variables['wcfFontFamilyGoogle']) && PACKAGE_ID
) {
418 $cssFile = FontManager
::getInstance()->getCssFilename(substr($variables['wcfFontFamilyGoogle'], 1, -1));
419 if (is_readable($cssFile)) {
420 $content .= file_get_contents($cssFile);
428 * Prepares a SCSS stylesheet for importing.
430 * @param string $filename
432 * @throws SystemException
434 protected function prepareFile($filename) {
435 if (!file_exists($filename) ||
!is_readable($filename)) {
436 throw new SystemException("Unable to access '".$filename."', does not exist or is not readable");
439 // use a relative path
440 $filename = FileUtil
::getRelativePath(WCF_DIR
, dirname($filename)) . basename($filename);
441 return '@import "'.$filename.'";'."\n";
445 * Compiles SCSS stylesheets into one CSS-stylesheet and writes them
446 * to filesystem. Please be aware not to append '.css' within $filename!
448 * @param string $filename
449 * @param string[] $files
450 * @param string[] $variables
451 * @param string $individualScss
452 * @param callable $callback
454 protected function compileStylesheet($filename, array $files, array $variables, $individualScss, callable
$callback) {
455 foreach ($variables as &$value) {
456 if (StringUtil
::startsWith($value, '../')) {
457 $value = '~"'.$value.'"';
462 $variables['wcfFontFamily'] = $variables['wcfFontFamilyFallback'];
463 if (!empty($variables['wcfFontFamilyGoogle'])) {
464 // The SCSS parser attempts to evaluate the variables, causing issues with font names that
465 // include logical operators such as "And" or "Or".
466 $variables['wcfFontFamilyGoogle'] = '"' . $variables['wcfFontFamilyGoogle'] . '"';
468 $variables['wcfFontFamily'] = $variables['wcfFontFamilyGoogle'] . ', ' . $variables['wcfFontFamily'];
471 // add options as SCSS variables
473 foreach (Option
::getOptions() as $constantName => $option) {
474 if (in_array($option->optionType
, static::$supportedOptionType)) {
475 $variables['wcf_option_'.mb_strtolower($constantName)] = is_int($option->optionValue
) ?
$option->optionValue
: '"'.$option->optionValue
.'"';
480 if (!isset($variables['apiVersion'])) $variables['apiVersion'] = Style
::API_VERSION
;
483 // workaround during setup
484 $variables['wcf_option_attachment_thumbnail_height'] = '~"210"';
485 $variables['wcf_option_attachment_thumbnail_width'] = '~"280"';
486 $variables['wcf_option_signature_max_image_height'] = '~"150"';
488 $variables['apiVersion'] = Style
::API_VERSION
;
491 // convert into numeric value for comparison, e.g. `3.1` -> `31`
492 $variables['apiVersion'] = str_replace('.', '', $variables['apiVersion']);
494 // build SCSS bootstrap
495 $scss = $this->bootstrap($variables);
496 foreach ($files as $file) {
497 $scss .= $this->prepareFile($file);
500 // append individual CSS/SCSS
501 if ($individualScss) {
502 $scss .= $individualScss;
506 $this->compiler
->setFormatter(CrunchedFormatter
::class);
507 $content = $this->compiler
->compile($scss);
509 catch (\Exception
$e) {
510 throw new SystemException("Could not compile SCSS: ".$e->getMessage(), 0, '', $e);
513 $content = $callback($content);
516 file_put_contents($filename.'.css', $content);
517 FileUtil
::makeWritable($filename.'.css');
519 // convert stylesheet to RTL
520 $content = StyleUtil
::convertCSSToRTL($content);
522 // force code boxes to be always LTR
523 $content .= "\n/* RTL fix for code boxes */\n";
524 $content .= ".redactor-layer pre { direction: ltr; text-align: left; }\n";
525 $content .= ".codeBoxCode { direction: ltr; } \n";
526 $content .= ".codeBox .codeBoxCode { padding-left: 7ch; padding-right: 0; } \n";
527 $content .= ".codeBox .codeBoxCode > code .codeBoxLine > a { margin-left: -7ch; margin-right: 0; text-align: right; } \n";
529 // write stylesheet for RTL
530 file_put_contents($filename.'-rtl.css', $content);
531 FileUtil
::makeWritable($filename.'-rtl.css');
535 * Returns the name of the CSS file for a specific style.
537 * @param Style $style
541 public static function getFilenameForStyle(Style
$style) {
542 return WCF_DIR
.'style/style-'.$style->styleID
;