Merge branch '5.2' into 5.3
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / style / StyleCompiler.class.php
1 <?php
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;
12 use wcf\system\WCF;
13 use wcf\util\FileUtil;
14 use wcf\util\StringUtil;
15 use wcf\util\StyleUtil;
16
17 /**
18 * Provides access to the SCSS PHP compiler.
19 *
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
24 */
25 class StyleCompiler extends SingletonFactory {
26 /**
27 * SCSS compiler object
28 * @var Compiler
29 */
30 protected $compiler = null;
31
32 /**
33 * Contains all files, which are compiled for a style.
34 * @var string[]
35 */
36 protected $files;
37
38 /**
39 * names of option types which are supported as additional variables
40 * @var string[]
41 */
42 public static $supportedOptionType = ['boolean', 'float', 'integer', 'radioButton', 'select'];
43
44 /**
45 * file used to store global SCSS declarations, relative to `WCF_DIR`
46 * @var string
47 */
48 const FILE_GLOBAL_VALUES = 'style/ui/zzz_wsc_style_global_values.scss';
49
50 /**
51 * registry keys for data storage
52 * @var string
53 */
54 const REGISTRY_GLOBAL_VALUES = 'styleGlobalValues';
55
56 /**
57 * @inheritDoc
58 */
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]);
66 }
67
68 /**
69 * Returns the default style variables as array.
70 *
71 * @return string[]
72 * @since 5.3
73 */
74 public static function getDefaultVariables() {
75 $variables = [];
76
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');
83
84 // see https://github.com/WoltLab/WCF/issues/2636
85 if (empty($variables['wcfPageThemeColor'])) {
86 $variables['wcfPageThemeColor'] = $variables['wcfHeaderBackground'];
87 }
88
89 return $variables;
90 }
91
92 /**
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 (!).
95 *
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
103 * @since 5.3
104 */
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']);
110 }
111
112 // add style image path
113 if ($imagePath) {
114 $imagePath = FileUtil::getRelativePath(WCF_DIR . 'style/', WCF_DIR . $imagePath);
115 $imagePath = FileUtil::addTrailingSlash(FileUtil::unifyDirSeparator($imagePath));
116 }
117 else {
118 $imagePath = '../images/';
119 }
120 $variables['style_image_path'] = "'{$imagePath}'";
121
122 // apply overrides
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];
128 }
129 }
130 unset($variables['overrideScss']);
131 }
132
133 // api version
134 $variables['apiVersion'] = $apiVersion;
135
136 $parameters = ['scss' => ''];
137 EventHandler::getInstance()->fireAction($this, 'compile', $parameters);
138
139 $files = $this->getFiles();
140
141 if ($customCustomSCSSFile !== null) {
142 if (($customSCSSFileKey = array_search(WCF_DIR . self::FILE_GLOBAL_VALUES, $files)) !== false) {
143 unset($files[$customSCSSFileKey]);
144 }
145
146 $files[] = $customCustomSCSSFile;
147 }
148
149 try {
150 $this->compileStylesheet(
151 FileUtil::addTrailingSlash($testFileDir) . 'style',
152 $files,
153 $variables,
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);
158 }
159 );
160 }
161 catch (\Exception $e) {
162 return $e;
163 }
164
165 return null;
166 }
167
168 /**
169 * Returns a array with all files, which should be compiled for a style.
170 *
171 * @return string[]
172 * @since 5.3
173 */
174 protected function getFiles() {
175 if (!$this->files) {
176 $files = $this->getCoreFiles();
177
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 ?
182 AND packageID <> ?
183 ORDER BY packageID";
184 $statement = WCF::getDB()->prepareStatement($sql);
185 $statement->execute([
186 'style/([a-zA-Z0-9\-\.]+)\.scss',
187 1
188 ]);
189 while ($row = $statement->fetchArray()) {
190 // the global values will always be evaluated last
191 if ($row['filename'] === self::FILE_GLOBAL_VALUES) {
192 continue;
193 }
194
195 $files[] = Application::getDirectory($row['application']).$row['filename'];
196 }
197
198 // global SCSS
199 if (file_exists(WCF_DIR . self::FILE_GLOBAL_VALUES)) {
200 $files[] = WCF_DIR . self::FILE_GLOBAL_VALUES;
201 }
202
203 $this->files = $files;
204 }
205
206 return $this->files;
207 }
208
209 /**
210 * Compiles SCSS stylesheets.
211 *
212 * @param Style $style
213 */
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']);
221 }
222
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));
228 }
229 $variables['style_image_path'] = "'{$imagePath}'";
230
231 // apply overrides
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];
237 }
238 }
239 unset($variables['overrideScss']);
240 }
241
242 // api version
243 $variables['apiVersion'] = $style->apiVersion;
244
245 $parameters = ['scss' => ''];
246 EventHandler::getInstance()->fireAction($this, 'compile', $parameters);
247
248 $this->compileStylesheet(
249 $this->getFilenameForStyle($style),
250 $this->getFiles(),
251 $variables,
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);
256 }
257 );
258 }
259
260 /**
261 * Compiles SCSS stylesheets for ACP usage.
262 */
263 public function compileACP() {
264 $files = $this->getCoreFiles();
265
266 // ACP uses a slightly different layout
267 $files[] = WCF_DIR . 'acp/style/layout.scss';
268
269 // include stylesheets from other apps in arbitrary order
270 if (PACKAGE_ID) {
271 foreach (ApplicationHandler::getInstance()->getApplications() as $application) {
272 $files = array_merge($files, $this->getAcpStylesheets($application));
273 }
274 }
275
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();
282 $variables = [];
283 while ($row = $statement->fetchArray()) {
284 $value = $row['defaultValue'];
285 if (empty($value)) {
286 $value = '~""';
287 }
288
289 $variables[$row['variableName']] = $value;
290 }
291
292 $variables['wcfFontFamily'] = $variables['wcfFontFamilyFallback'];
293 if (!empty($variables['wcfFontFamilyGoogle'])) {
294 $variables['wcfFontFamily'] = '"' . $variables['wcfFontFamilyGoogle'] . '", ' . $variables['wcfFontFamily'];
295 }
296
297 $variables['style_image_path'] = "'../images/'";
298
299 $this->compileStylesheet(
300 WCF_DIR.'acp/style/style',
301 $files,
302 $variables,
303 '',
304 function($content) {
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);
309
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);
312 }
313 );
314 }
315
316 /**
317 * Returns a list of common stylesheets provided by the core.
318 *
319 * @return string[] list of common stylesheets
320 */
321 protected function getCoreFiles() {
322 $files = [];
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)) {
326 continue;
327 }
328
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)) {
333 continue;
334 }
335
336 $files[] = $file.$innerFile;
337 }
338 closedir($innerHandle);
339 }
340 }
341
342 closedir($handle);
343
344 // directory order is not deterministic in some cases
345 sort($files);
346 }
347
348 return $files;
349 }
350
351 /**
352 * Returns the list of SCSS stylesheets of an application.
353 *
354 * @param Application $application
355 * @return string[]
356 */
357 protected function getAcpStylesheets(Application $application) {
358 if ($application->packageID == 1) return [];
359
360 $files = [];
361
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) {
366 $files[] = $file;
367 }
368 }
369
370 return $files;
371 }
372
373 /**
374 * Prepares the style compiler by adding variables to environment.
375 *
376 * @param string[] $variables
377 * @return string
378 */
379 protected function bootstrap(array $variables) {
380 // add reset like a boss
381 $content = $this->prepareFile(WCF_DIR.'style/bootstrap/reset.scss');
382
383 // apply style variables
384 $this->compiler->setVariables($variables);
385
386 // add mixins
387 $content .= $this->prepareFile(WCF_DIR.'style/bootstrap/mixin.scss');
388
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);
392 }
393
394 if (ApplicationHandler::getInstance()->isMultiDomainSetup()) {
395 $content .= <<<'EOT'
396 @function getFont($filename, $family: "/", $version: "") {
397 @return "../font/getFont.php?family=" + $family + "&filename=" + $filename + "&v=" + $version;
398 }
399 EOT;
400 }
401 else {
402 $content .= <<<'EOT'
403 @function getFont($filename, $family: "/", $version: "") {
404 @if ($family != "") {
405 $family: "families/" + $family + "/";
406 }
407 @if ($version != "") {
408 $version: "?v=" + $version;
409 }
410
411 @return "../font/" + $family + $filename + $version;
412 }
413 EOT;
414 }
415
416 // add google fonts
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);
421 }
422 }
423
424 return $content;
425 }
426
427 /**
428 * Prepares a SCSS stylesheet for importing.
429 *
430 * @param string $filename
431 * @return string
432 * @throws SystemException
433 */
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");
437 }
438
439 // use a relative path
440 $filename = FileUtil::getRelativePath(WCF_DIR, dirname($filename)) . basename($filename);
441 return '@import "'.$filename.'";'."\n";
442 }
443
444 /**
445 * Compiles SCSS stylesheets into one CSS-stylesheet and writes them
446 * to filesystem. Please be aware not to append '.css' within $filename!
447 *
448 * @param string $filename
449 * @param string[] $files
450 * @param string[] $variables
451 * @param string $individualScss
452 * @param callable $callback
453 */
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.'"';
458 }
459 }
460 unset($value);
461
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'] . '"';
467
468 $variables['wcfFontFamily'] = $variables['wcfFontFamilyGoogle'] . ', ' . $variables['wcfFontFamily'];
469 }
470
471 // add options as SCSS variables
472 if (PACKAGE_ID) {
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.'"';
476 }
477 }
478
479 // api version
480 if (!isset($variables['apiVersion'])) $variables['apiVersion'] = Style::API_VERSION;
481 }
482 else {
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"';
487
488 $variables['apiVersion'] = Style::API_VERSION;
489 }
490
491 // convert into numeric value for comparison, e.g. `3.1` -> `31`
492 $variables['apiVersion'] = str_replace('.', '', $variables['apiVersion']);
493
494 // build SCSS bootstrap
495 $scss = $this->bootstrap($variables);
496 foreach ($files as $file) {
497 $scss .= $this->prepareFile($file);
498 }
499
500 // append individual CSS/SCSS
501 if ($individualScss) {
502 $scss .= $individualScss;
503 }
504
505 try {
506 $this->compiler->setFormatter(CrunchedFormatter::class);
507 $content = $this->compiler->compile($scss);
508 }
509 catch (\Exception $e) {
510 throw new SystemException("Could not compile SCSS: ".$e->getMessage(), 0, '', $e);
511 }
512
513 $content = $callback($content);
514
515 // write stylesheet
516 file_put_contents($filename.'.css', $content);
517 FileUtil::makeWritable($filename.'.css');
518
519 // convert stylesheet to RTL
520 $content = StyleUtil::convertCSSToRTL($content);
521
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";
528
529 // write stylesheet for RTL
530 file_put_contents($filename.'-rtl.css', $content);
531 FileUtil::makeWritable($filename.'-rtl.css');
532 }
533
534 /**
535 * Returns the name of the CSS file for a specific style.
536 *
537 * @param Style $style
538 * @return string
539 * @since 5.3
540 */
541 public static function getFilenameForStyle(Style $style) {
542 return WCF_DIR.'style/style-'.$style->styleID;
543 }
544 }