2 // This file is part of Moodle - http://moodle.org/
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.
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/>.
18 * This file contains CSS file serving functions.
20 * NOTE: these functions are not expected to be used from any addons.
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);
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) {
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);
64 // If we need to chunk the CSS for browsers that are sub-par.
65 $css = css_chunk_by_selector_count($csscontent, $chunkurl);
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);
73 // If there is more than one file and this is not the last file.
74 $filename = preg_replace('#\.css$#', '.'.$count.'.css', $csspath);
77 css_write_file($filename, $content);
81 ignore_user_abort(false);
82 if (connection_aborted()) {
90 * @param string $filename
91 * @param string $content
93 function css_write_file($filename, $content) {
95 if ($fp = fopen($filename.'.tmp', 'xb')) {
96 fwrite($fp, $content);
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.
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];
158 // Is that a media query that I see coming towards us?
160 if (!$inmedia && substr($css, $offset, 5) === 'media') {
165 // So we are entering a rule or a media query...
169 $mediacoming = false;
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 === ',') {
182 // We reached the end of something.
184 // Oh, we are in a media query.
187 // This is the end of the media query.
190 // We were in a rule, in the media query.
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.
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;
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.',
237 // Now that we have got the offets, we can chunk the CSS.
238 $offsetcount = count($offsets);
239 foreach ($offsets as $key => $index) {
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.
262 $slashargs = strpos($importurl, '.php?') === false;
263 $parts = count($chunks);
264 for ($i = 1; $i < $parts; $i++
) {
266 $importcss .= "@import url({$importurl}/chunk{$i});\n";
268 $importcss .= "@import url({$importurl}&chunk={$i});\n";
271 $importcss .= end($chunks);
272 $chunks[key($chunks)] = $importcss;
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');
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));
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');
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));
333 * Sends CSS directly without caching it.
335 * This function takes a raw CSS string, optimises it if required, and then
337 * Turning both themedesignermode and CSS optimiser on at the same time is awful
338 * for performance because of the optimiser running here. However it was done so
339 * that theme designers could utilise the optimised output during development to
340 * help them optimise their CSS... not that they should write lazy CSS.
344 function css_send_uncached_css($css) {
345 header('Content-Disposition: inline; filename="styles_debug.php"');
346 header('Last-Modified: '. gmdate('D, d M Y H:i:s', time()) .' GMT');
347 header('Expires: '. gmdate('D, d M Y H:i:s', time() + THEME_DESIGNER_CACHE_LIFETIME
) .' GMT');
349 header('Accept-Ranges: none');
350 header('Content-Type: text/css; charset=utf-8');
352 if (is_array($css)) {
353 $css = implode("\n\n", $css);
360 * Send file not modified headers
362 * @param int $lastmodified
363 * @param string $etag
365 function css_send_unmodified($lastmodified, $etag) {
366 // 90 days only - based on Moodle point release cadence being every 3 months.
367 $lifetime = 60 * 60 * 24 * 90;
368 header('HTTP/1.1 304 Not Modified');
369 header('Expires: '. gmdate('D, d M Y H:i:s', time() +
$lifetime) .' GMT');
370 header('Cache-Control: public, max-age='.$lifetime);
371 header('Content-Type: text/css; charset=utf-8');
372 header('Etag: "'.$etag.'"');
374 header('Last-Modified: '. gmdate('D, d M Y H:i:s', $lastmodified) .' GMT');
380 * Sends a 404 message about CSS not being found.
382 function css_send_css_not_found() {
383 header('HTTP/1.0 404 not found');
384 die('CSS was not found, sorry.');