Merge branch 'MDL-66614_36' of https://github.com/timhunt/moodle into MOODLE_36_STABLE
[moodle.git] / lib / csslib.php
blob669ca974dc4ffe2e179832f4dfd4611ccba02580
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * This file contains CSS file serving functions.
20 * NOTE: these functions are not expected to be used from any addons.
22 * @package core
23 * @copyright 2012 Sam Hemelryk
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27 defined('MOODLE_INTERNAL') || die();
29 if (!defined('THEME_DESIGNER_CACHE_LIFETIME')) {
30 // This can be also set in config.php file,
31 // it needs to be higher than the time it takes to generate all CSS content.
32 define('THEME_DESIGNER_CACHE_LIFETIME', 10);
35 /**
36 * Stores CSS in a file at the given path.
38 * This function either succeeds or throws an exception.
40 * @param theme_config $theme The theme that the CSS belongs to.
41 * @param string $csspath The path to store the CSS at.
42 * @param string $csscontent the complete CSS in one string
43 * @param bool $chunk If set to true these files will be chunked to ensure
44 * that no one file contains more than 4095 selectors.
45 * @param string $chunkurl If the CSS is be chunked then we need to know the URL
46 * to use for the chunked files.
48 function css_store_css(theme_config $theme, $csspath, $csscontent, $chunk = false, $chunkurl = null) {
49 global $CFG;
51 clearstatcache();
52 if (!file_exists(dirname($csspath))) {
53 @mkdir(dirname($csspath), $CFG->directorypermissions, true);
56 // Prevent serving of incomplete file from concurrent request,
57 // the rename() should be more atomic than fwrite().
58 ignore_user_abort(true);
60 // First up write out the single file for all those using decent browsers.
61 css_write_file($csspath, $csscontent);
63 if ($chunk) {
64 // If we need to chunk the CSS for browsers that are sub-par.
65 $css = css_chunk_by_selector_count($csscontent, $chunkurl);
66 $files = count($css);
67 $count = 1;
68 foreach ($css as $content) {
69 if ($count === $files) {
70 // If there is more than one file and this IS the last file.
71 $filename = preg_replace('#\.css$#', '.0.css', $csspath);
72 } else {
73 // If there is more than one file and this is not the last file.
74 $filename = preg_replace('#\.css$#', '.'.$count.'.css', $csspath);
76 $count++;
77 css_write_file($filename, $content);
81 ignore_user_abort(false);
82 if (connection_aborted()) {
83 die;
87 /**
88 * Writes a CSS file.
90 * @param string $filename
91 * @param string $content
93 function css_write_file($filename, $content) {
94 global $CFG;
95 if ($fp = fopen($filename.'.tmp', 'xb')) {
96 fwrite($fp, $content);
97 fclose($fp);
98 rename($filename.'.tmp', $filename);
99 @chmod($filename, $CFG->filepermissions);
100 @unlink($filename.'.tmp'); // Just in case anything fails.
105 * Takes CSS and chunks it if the number of selectors within it exceeds $maxselectors.
107 * The chunking will not split a group of selectors, or a media query. That means that
108 * if n > $maxselectors and there are n selectors grouped together,
109 * they will not be chunked and you could end up with more selectors than desired.
110 * The same applies for a media query that has more than n selectors.
112 * Also, as we do not split group of selectors or media queries, the chunking might
113 * not be as optimal as it could be, having files with less selectors than it could
114 * potentially contain.
116 * String functions used here are not compliant with unicode characters. But that is
117 * not an issue as the syntax of CSS is using ASCII codes. Even if we have unicode
118 * characters in comments, or in the property 'content: ""', it will behave correcly.
120 * Please note that this strips out the comments if chunking happens.
122 * @param string $css The CSS to chunk.
123 * @param string $importurl The URL to use for import statements.
124 * @param int $maxselectors The number of selectors to limit a chunk to.
125 * @param int $buffer Not used any more.
126 * @return array An array of CSS chunks.
128 function css_chunk_by_selector_count($css, $importurl, $maxselectors = 4095, $buffer = 50) {
130 // Check if we need to chunk this CSS file.
131 $count = substr_count($css, ',') + substr_count($css, '{');
132 if ($count < $maxselectors) {
133 // The number of selectors is less then the max - we're fine.
134 return array($css);
137 $chunks = array(); // The final chunks.
138 $offsets = array(); // The indexes to chunk at.
139 $offset = 0; // The current offset.
140 $selectorcount = 0; // The number of selectors since the last split.
141 $lastvalidoffset = 0; // The last valid index to split at.
142 $lastvalidoffsetselectorcount = 0; // The number of selectors used at the time were could split.
143 $inrule = 0; // The number of rules we are in, should not be greater than 1.
144 $inmedia = false; // Whether or not we are in a media query.
145 $mediacoming = false; // Whether or not we are expeting a media query.
146 $currentoffseterror = null; // Not null when we have recorded an error for the current split.
147 $offseterrors = array(); // The offsets where we found errors.
149 // Remove the comments. Because it's easier, safer and probably a lot of other good reasons.
150 $css = preg_replace('#/\*(.*?)\*/#s', '', $css);
151 $strlen = strlen($css);
153 // Walk through the CSS content character by character.
154 for ($i = 1; $i <= $strlen; $i++) {
155 $char = $css[$i - 1];
156 $offset = $i;
158 // Is that a media query that I see coming towards us?
159 if ($char === '@') {
160 if (!$inmedia && substr($css, $offset, 5) === 'media') {
161 $mediacoming = true;
165 // So we are entering a rule or a media query...
166 if ($char === '{') {
167 if ($mediacoming) {
168 $inmedia = true;
169 $mediacoming = false;
170 } else {
171 $inrule++;
172 $selectorcount++;
176 // Let's count the number of selectors, but only if we are not in a rule, or in
177 // the definition of a media query, as they can contain commas too.
178 if (!$mediacoming && !$inrule && $char === ',') {
179 $selectorcount++;
182 // We reached the end of something.
183 if ($char === '}') {
184 // Oh, we are in a media query.
185 if ($inmedia) {
186 if (!$inrule) {
187 // This is the end of the media query.
188 $inmedia = false;
189 } else {
190 // We were in a rule, in the media query.
191 $inrule--;
193 } else {
194 $inrule--;
195 // Handle stupid broken CSS where there are too many } brackets,
196 // as this can cause it to break (with chunking) where it would
197 // coincidentally have worked otherwise.
198 if ($inrule < 0) {
199 $inrule = 0;
203 // We are not in a media query, and there is no pending rule, it is safe to split here.
204 if (!$inmedia && !$inrule) {
205 $lastvalidoffset = $offset;
206 $lastvalidoffsetselectorcount = $selectorcount;
210 // Alright, this is splitting time...
211 if ($selectorcount > $maxselectors) {
212 if (!$lastvalidoffset) {
213 // We must have reached more selectors into one set than we were allowed. That means that either
214 // the chunk size value is too small, or that we have a gigantic group of selectors, or that a media
215 // query contains more selectors than the chunk size. We have to ignore this because we do not
216 // support split inside a group of selectors or media query.
217 if ($currentoffseterror === null) {
218 $currentoffseterror = $offset;
219 $offseterrors[] = $currentoffseterror;
221 } else {
222 // We identify the offset to split at and reset the number of selectors found from there.
223 $offsets[] = $lastvalidoffset;
224 $selectorcount = $selectorcount - $lastvalidoffsetselectorcount;
225 $lastvalidoffset = 0;
226 $currentoffseterror = null;
231 // Report offset errors.
232 if (!empty($offseterrors)) {
233 debugging('Could not find a safe place to split at offset(s): ' . implode(', ', $offseterrors) . '. Those were ignored.',
234 DEBUG_DEVELOPER);
237 // Now that we have got the offets, we can chunk the CSS.
238 $offsetcount = count($offsets);
239 foreach ($offsets as $key => $index) {
240 $start = 0;
241 if ($key > 0) {
242 $start = $offsets[$key - 1];
244 // From somewhere up to the offset.
245 $chunks[] = substr($css, $start, $index - $start);
247 // Add the last chunk (if there is one), from the last offset to the end of the string.
248 if (end($offsets) != $strlen) {
249 $chunks[] = substr($css, end($offsets));
252 // The array $chunks now contains CSS split into perfect sized chunks.
253 // Import statements can only appear at the very top of a CSS file.
254 // Imported sheets are applied in the the order they are imported and
255 // are followed by the contents of the CSS.
256 // This is terrible for performance.
257 // It means we must put the import statements at the top of the last chunk
258 // to ensure that things are always applied in the correct order.
259 // This way the chunked files are included in the order they were chunked
260 // followed by the contents of the final chunk in the actual sheet.
261 $importcss = '';
262 $slashargs = strpos($importurl, '.php?') === false;
263 $parts = count($chunks);
264 for ($i = 1; $i < $parts; $i++) {
265 if ($slashargs) {
266 $importcss .= "@import url({$importurl}/chunk{$i});\n";
267 } else {
268 $importcss .= "@import url({$importurl}&chunk={$i});\n";
271 $importcss .= end($chunks);
272 $chunks[key($chunks)] = $importcss;
274 return $chunks;
278 * Sends a cached CSS file
280 * This function sends the cached CSS file. Remember it is generated on the first
281 * request, then optimised/minified, and finally cached for serving.
283 * @param string $csspath The path to the CSS file we want to serve.
284 * @param string $etag The revision to make sure we utilise any caches.
286 function css_send_cached_css($csspath, $etag) {
287 // 90 days only - based on Moodle point release cadence being every 3 months.
288 $lifetime = 60 * 60 * 24 * 90;
290 header('Etag: "'.$etag.'"');
291 header('Content-Disposition: inline; filename="styles.php"');
292 header('Last-Modified: '. gmdate('D, d M Y H:i:s', filemtime($csspath)) .' GMT');
293 header('Expires: '. gmdate('D, d M Y H:i:s', time() + $lifetime) .' GMT');
294 header('Pragma: ');
295 header('Cache-Control: public, max-age='.$lifetime.', immutable');
296 header('Accept-Ranges: none');
297 header('Content-Type: text/css; charset=utf-8');
298 if (!min_enable_zlib_compression()) {
299 header('Content-Length: '.filesize($csspath));
302 readfile($csspath);
303 die;
307 * Sends a cached CSS content
309 * @param string $csscontent The actual CSS markup.
310 * @param string $etag The revision to make sure we utilise any caches.
312 function css_send_cached_css_content($csscontent, $etag) {
313 // 90 days only - based on Moodle point release cadence being every 3 months.
314 $lifetime = 60 * 60 * 24 * 90;
316 header('Etag: "'.$etag.'"');
317 header('Content-Disposition: inline; filename="styles.php"');
318 header('Last-Modified: '. gmdate('D, d M Y H:i:s', time()) .' GMT');
319 header('Expires: '. gmdate('D, d M Y H:i:s', time() + $lifetime) .' GMT');
320 header('Pragma: ');
321 header('Cache-Control: public, max-age='.$lifetime.', immutable');
322 header('Accept-Ranges: none');
323 header('Content-Type: text/css; charset=utf-8');
324 if (!min_enable_zlib_compression()) {
325 header('Content-Length: '.strlen($csscontent));
328 echo($csscontent);
329 die;
333 * Sends CSS directly and disables all caching.
334 * The Content-Length of the body is also included, but the script is not ended.
336 * @param string $css The CSS content to send
338 function css_send_temporary_css($css) {
339 header('Cache-Control: no-cache, no-store, must-revalidate');
340 header('Pragma: no-cache');
341 header('Expires: 0');
342 header('Content-Disposition: inline; filename="styles_debug.php"');
343 header('Last-Modified: '. gmdate('D, d M Y H:i:s', time()) .' GMT');
344 header('Accept-Ranges: none');
345 header('Content-Type: text/css; charset=utf-8');
346 header('Content-Length: ' . strlen($css));
348 echo $css;
352 * Sends CSS directly without caching it.
354 * This function takes a raw CSS string, optimises it if required, and then
355 * serves it.
356 * Turning both themedesignermode and CSS optimiser on at the same time is awful
357 * for performance because of the optimiser running here. However it was done so
358 * that theme designers could utilise the optimised output during development to
359 * help them optimise their CSS... not that they should write lazy CSS.
361 * @param string $css
363 function css_send_uncached_css($css) {
364 header('Content-Disposition: inline; filename="styles_debug.php"');
365 header('Last-Modified: '. gmdate('D, d M Y H:i:s', time()) .' GMT');
366 header('Expires: '. gmdate('D, d M Y H:i:s', time() + THEME_DESIGNER_CACHE_LIFETIME) .' GMT');
367 header('Pragma: ');
368 header('Accept-Ranges: none');
369 header('Content-Type: text/css; charset=utf-8');
371 if (is_array($css)) {
372 $css = implode("\n\n", $css);
374 echo $css;
375 die;
379 * Send file not modified headers
381 * @param int $lastmodified
382 * @param string $etag
384 function css_send_unmodified($lastmodified, $etag) {
385 // 90 days only - based on Moodle point release cadence being every 3 months.
386 $lifetime = 60 * 60 * 24 * 90;
387 header('HTTP/1.1 304 Not Modified');
388 header('Expires: '. gmdate('D, d M Y H:i:s', time() + $lifetime) .' GMT');
389 header('Cache-Control: public, max-age='.$lifetime);
390 header('Content-Type: text/css; charset=utf-8');
391 header('Etag: "'.$etag.'"');
392 if ($lastmodified) {
393 header('Last-Modified: '. gmdate('D, d M Y H:i:s', $lastmodified) .' GMT');
395 die;
399 * Sends a 404 message about CSS not being found.
401 function css_send_css_not_found() {
402 header('HTTP/1.0 404 not found');
403 die('CSS was not found, sorry.');