Merge branch '3.1' into 5.2
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / style / StyleCompiler.class.php
1 <?php
2 namespace wcf\system\style;
3 use Leafo\ScssPhp\Compiler;
4 use wcf\data\application\Application;
5 use wcf\data\option\Option;
6 use wcf\data\style\Style;
7 use wcf\system\application\ApplicationHandler;
8 use wcf\system\event\EventHandler;
9 use wcf\system\exception\SystemException;
10 use wcf\system\SingletonFactory;
11 use wcf\system\WCF;
12 use wcf\util\FileUtil;
13 use wcf\util\StringUtil;
14 use wcf\util\StyleUtil;
15
16 /**
17 * Provides access to the SCSS PHP compiler.
18 *
19 * @author Alexander Ebert
20 * @copyright 2001-2019 WoltLab GmbH
21 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
22 * @package WoltLabSuite\Core\System\Style
23 */
24 class StyleCompiler extends SingletonFactory {
25 /**
26 * SCSS compiler object
27 * @var \Leafo\ScssPhp\Compiler
28 */
29 protected $compiler = null;
30
31 /**
32 * names of option types which are supported as additional variables
33 * @var string[]
34 */
35 public static $supportedOptionType = ['boolean', 'float', 'integer', 'radioButton', 'select'];
36
37 /**
38 * file used to store global SCSS declarations, relative to `WCF_DIR`
39 * @var string
40 */
41 const FILE_GLOBAL_VALUES = 'style/ui/zzz_wsc_style_global_values.scss';
42
43 /**
44 * registry keys for data storage
45 * @var string
46 */
47 const REGISTRY_GLOBAL_VALUES = 'styleGlobalValues';
48
49 /**
50 * @inheritDoc
51 */
52 protected function init() {
53 require_once(WCF_DIR.'lib/system/style/scssphp/scss.inc.php');
54 $this->compiler = new Compiler();
55 // Disable Unicode support because of its horrible performance (7x slowdown)
56 // https://github.com/WoltLab/WCF/pull/2736#issuecomment-416084079
57 $this->compiler->setEncoding('iso8859-1');
58 $this->compiler->setImportPaths([WCF_DIR]);
59 }
60
61 /**
62 * Compiles SCSS stylesheets.
63 *
64 * @param Style $style
65 */
66 public function compile(Style $style) {
67 $files = $this->getCoreFiles();
68
69 // read stylesheets in dependency order
70 $sql = "SELECT filename, application
71 FROM wcf".WCF_N."_package_installation_file_log
72 WHERE CONVERT(filename using utf8) REGEXP ?
73 AND packageID <> ?
74 ORDER BY packageID";
75 $statement = WCF::getDB()->prepareStatement($sql);
76 $statement->execute([
77 'style/([a-zA-Z0-9\-\.]+)\.scss',
78 1
79 ]);
80 while ($row = $statement->fetchArray()) {
81 // the global values will always be evaluated last
82 if ($row['filename'] === self::FILE_GLOBAL_VALUES) {
83 continue;
84 }
85
86 $files[] = Application::getDirectory($row['application']).$row['filename'];
87 }
88
89 // global SCSS
90 if (file_exists(WCF_DIR . self::FILE_GLOBAL_VALUES)) {
91 $files[] = WCF_DIR . self::FILE_GLOBAL_VALUES;
92 }
93
94 // get style variables
95 $variables = $style->getVariables();
96 $individualScss = '';
97 if (isset($variables['individualScss'])) {
98 $individualScss = $variables['individualScss'];
99 unset($variables['individualScss']);
100 }
101
102 // add style image path
103 $imagePath = '../images/';
104 if ($style->imagePath) {
105 $imagePath = FileUtil::getRelativePath(WCF_DIR . 'style/', WCF_DIR . $style->imagePath);
106 $imagePath = FileUtil::addTrailingSlash(FileUtil::unifyDirSeparator($imagePath));
107 }
108 $variables['style_image_path'] = "'{$imagePath}'";
109
110 // apply overrides
111 if (isset($variables['overrideScss'])) {
112 $lines = explode("\n", StringUtil::unifyNewlines($variables['overrideScss']));
113 foreach ($lines as $line) {
114 if (preg_match('~^@([a-zA-Z]+): ?([@a-zA-Z0-9 ,\.\(\)\%\#-]+);$~', $line, $matches)) {
115 $variables[$matches[1]] = $matches[2];
116 }
117 }
118 unset($variables['overrideScss']);
119 }
120
121 // api version
122 $variables['apiVersion'] = $style->apiVersion;
123
124 $parameters = ['scss' => ''];
125 EventHandler::getInstance()->fireAction($this, 'compile', $parameters);
126
127 $this->compileStylesheet(
128 WCF_DIR.'style/style-'.$style->styleID,
129 $files,
130 $variables,
131 $individualScss . (!empty($parameters['scss']) ? "\n" . $parameters['scss'] : ''),
132 function($content) use ($style) {
133 return "/* stylesheet for '".$style->styleName."', generated on ".gmdate('r')." -- DO NOT EDIT */\n\n" . $content;
134 }
135 );
136 }
137
138 /**
139 * Compiles SCSS stylesheets for ACP usage.
140 */
141 public function compileACP() {
142 if (substr(WCF_VERSION, 0, 3) == '2.1') {
143 // work-around for wcf2.1 update
144 return;
145 }
146
147 $files = $this->getCoreFiles();
148
149 // ACP uses a slightly different layout
150 $files[] = WCF_DIR . 'acp/style/layout.scss';
151
152 // include stylesheets from other apps in arbitrary order
153 if (PACKAGE_ID) {
154 foreach (ApplicationHandler::getInstance()->getApplications() as $application) {
155 $files = array_merge($files, $this->getAcpStylesheets($application));
156 }
157 }
158
159 // read default values
160 $sql = "SELECT variableName, defaultValue
161 FROM wcf".WCF_N."_style_variable
162 ORDER BY variableID ASC";
163 $statement = WCF::getDB()->prepareStatement($sql);
164 $statement->execute();
165 $variables = [];
166 while ($row = $statement->fetchArray()) {
167 $value = $row['defaultValue'];
168 if (empty($value)) {
169 $value = '~""';
170 }
171
172 $variables[$row['variableName']] = $value;
173 }
174
175 $variables['wcfFontFamily'] = $variables['wcfFontFamilyFallback'];
176 if (!empty($variables['wcfFontFamilyGoogle'])) {
177 $variables['wcfFontFamily'] = '"' . $variables['wcfFontFamilyGoogle'] . '", ' . $variables['wcfFontFamily'];
178 }
179
180 $variables['style_image_path'] = "'../images/'";
181
182 $this->compileStylesheet(
183 WCF_DIR.'acp/style/style',
184 $files,
185 $variables,
186 '',
187 function($content) {
188 // fix relative paths
189 $content = str_replace('../font/', '../../font/', $content);
190 $content = str_replace('../icon/', '../../icon/', $content);
191 $content = preg_replace('~\.\./images/~', '../../images/', $content);
192
193 return "/* stylesheet for ACP, generated on ".gmdate('r')." -- DO NOT EDIT */\n\n" . $content;
194 }
195 );
196 }
197
198 /**
199 * Returns a list of common stylesheets provided by the core.
200 *
201 * @return string[] list of common stylesheets
202 */
203 protected function getCoreFiles() {
204 $files = [];
205 if ($handle = opendir(WCF_DIR.'style/')) {
206 while (($file = readdir($handle)) !== false) {
207 if ($file === '.' || $file === '..' || $file === 'bootstrap' || is_file(WCF_DIR.'style/'.$file)) {
208 continue;
209 }
210
211 $file = WCF_DIR."style/{$file}/";
212 if ($innerHandle = opendir($file)) {
213 while (($innerFile = readdir($innerHandle)) !== false) {
214 if ($innerFile === '.' || $innerFile === '..' || !is_file($file.$innerFile) || !preg_match('~^[a-zA-Z0-9\-\.]+\.scss$~', $innerFile)) {
215 continue;
216 }
217
218 $files[] = $file.$innerFile;
219 }
220 closedir($innerHandle);
221 }
222 }
223
224 closedir($handle);
225
226 // directory order is not deterministic in some cases
227 sort($files);
228 }
229
230 return $files;
231 }
232
233 /**
234 * Returns the list of SCSS stylesheets of an application.
235 *
236 * @param Application $application
237 * @return string[]
238 */
239 protected function getAcpStylesheets(Application $application) {
240 if ($application->packageID == 1) return [];
241
242 $files = [];
243
244 $basePath = FileUtil::addTrailingSlash(FileUtil::getRealPath(WCF_DIR . $application->getPackage()->packageDir)) . 'acp/style/';
245 $result = glob($basePath . '*.scss');
246 if (is_array($result)) {
247 foreach ($result as $file) {
248 $files[] = $file;
249 }
250 }
251
252 return $files;
253 }
254
255 /**
256 * Prepares the style compiler by adding variables to environment.
257 *
258 * @param string[] $variables
259 * @return string
260 */
261 protected function bootstrap(array $variables) {
262 // add reset like a boss
263 $content = $this->prepareFile(WCF_DIR.'style/bootstrap/reset.scss');
264
265 // apply style variables
266 $this->compiler->setVariables($variables);
267
268 // add mixins
269 $content .= $this->prepareFile(WCF_DIR.'style/bootstrap/mixin.scss');
270
271 // add newer mixins added with version 3.0
272 foreach (glob(WCF_DIR.'style/bootstrap/mixin/*.scss') as $mixin) {
273 $content .= $this->prepareFile($mixin);
274 }
275
276 return $content;
277 }
278
279 /**
280 * Prepares a SCSS stylesheet for importing.
281 *
282 * @param string $filename
283 * @return string
284 * @throws SystemException
285 */
286 protected function prepareFile($filename) {
287 if (!file_exists($filename) || !is_readable($filename)) {
288 throw new SystemException("Unable to access '".$filename."', does not exist or is not readable");
289 }
290
291 // use a relative path
292 $filename = FileUtil::getRelativePath(WCF_DIR, dirname($filename)) . basename($filename);
293 return '@import "'.$filename.'";'."\n";
294 }
295
296 /**
297 * Compiles SCSS stylesheets into one CSS-stylesheet and writes them
298 * to filesystem. Please be aware not to append '.css' within $filename!
299 *
300 * @param string $filename
301 * @param string[] $files
302 * @param string[] $variables
303 * @param string $individualScss
304 * @param callable $callback
305 * @throws SystemException
306 */
307 protected function compileStylesheet($filename, array $files, array $variables, $individualScss, callable $callback) {
308 foreach ($variables as &$value) {
309 if (StringUtil::startsWith($value, '../')) {
310 $value = '~"'.$value.'"';
311 }
312 }
313 unset($value);
314
315 $variables['wcfFontFamily'] = $variables['wcfFontFamilyFallback'];
316 if (!empty($variables['wcfFontFamilyGoogle'])) {
317 // The SCSS parser attempts to evaluate the variables, causing issues with font names that
318 // include logical operators such as "And" or "Or".
319 $variables['wcfFontFamilyGoogle'] = '"' . $variables['wcfFontFamilyGoogle'] . '"';
320
321 $variables['wcfFontFamily'] = $variables['wcfFontFamilyGoogle'] . ', ' . $variables['wcfFontFamily'];
322 }
323
324 // add options as SCSS variables
325 if (PACKAGE_ID) {
326 foreach (Option::getOptions() as $constantName => $option) {
327 if (in_array($option->optionType, static::$supportedOptionType)) {
328 $variables['wcf_option_'.mb_strtolower($constantName)] = is_int($option->optionValue) ? $option->optionValue : '"'.$option->optionValue.'"';
329 }
330 }
331
332 // api version
333 if (!isset($variables['apiVersion'])) $variables['apiVersion'] = Style::API_VERSION;
334 }
335 else {
336 // workaround during setup
337 $variables['wcf_option_attachment_thumbnail_height'] = '~"210"';
338 $variables['wcf_option_attachment_thumbnail_width'] = '~"280"';
339 $variables['wcf_option_signature_max_image_height'] = '~"150"';
340
341 $variables['apiVersion'] = Style::API_VERSION;
342 }
343
344 // convert into numeric value for comparison, e.g. `3.1` -> `31`
345 $variables['apiVersion'] = str_replace('.', '', $variables['apiVersion']);
346
347 // build SCSS bootstrap
348 $scss = $this->bootstrap($variables);
349 foreach ($files as $file) {
350 $scss .= $this->prepareFile($file);
351 }
352
353 // append individual CSS/SCSS
354 if ($individualScss) {
355 $scss .= $individualScss;
356 }
357
358 try {
359 $this->compiler->setFormatter('Leafo\ScssPhp\Formatter\Crunched');
360 $content = $this->compiler->compile($scss);
361 }
362 catch (\Exception $e) {
363 throw new SystemException("Could not compile SCSS: ".$e->getMessage(), 0, '', $e);
364 }
365
366 $content = $callback($content);
367
368 // write stylesheet
369 file_put_contents($filename.'.css', $content);
370 FileUtil::makeWritable($filename.'.css');
371
372 // convert stylesheet to RTL
373 $content = StyleUtil::convertCSSToRTL($content);
374
375 // force code boxes to be always LTR
376 $content .= "\n/* RTL fix for code boxes */\n";
377 $content .= '.codeBox > div > ol > li > span:last-child, .redactor-layer pre { direction: ltr; text-align: left; } .codeBox > div > ol > li > span:last-child { display: block; }';
378
379 // write stylesheet for RTL
380 file_put_contents($filename.'-rtl.css', $content);
381 FileUtil::makeWritable($filename.'-rtl.css');
382 }
383 }