001/* 002 * Copyright 2018 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.core.minimize.css; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.net.URI; 021import java.net.URISyntaxException; 022import java.nio.charset.StandardCharsets; 023import java.util.regex.Matcher; 024 025import org.apache.avalon.framework.activity.Initializable; 026import org.apache.avalon.framework.component.Component; 027import org.apache.avalon.framework.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.cocoon.ProcessingException; 030import org.apache.commons.io.FilenameUtils; 031import org.apache.commons.io.IOUtils; 032import org.apache.commons.io.input.BOMInputStream; 033import org.apache.commons.lang3.StringUtils; 034import org.apache.excalibur.source.Source; 035 036import org.ametys.core.minimize.AbstractMinimizeManager; 037import org.ametys.plugins.core.ui.resources.css.CSSFileHelper; 038import org.ametys.plugins.core.ui.resources.css.JSASSResourceURIExtensionPoint; 039 040import com.google.debugging.sourcemap.SourceMapGeneratorV3; 041import com.google.debugging.sourcemap.SourceMapParseException; 042 043import io.bit3.jsass.CompilationException; 044import io.bit3.jsass.Compiler; 045import io.bit3.jsass.Options; 046import io.bit3.jsass.Output; 047import io.bit3.jsass.OutputStyle; 048import io.bit3.jsass.importer.Importer; 049 050/** 051 * Minimize manager for CSS files 052 */ 053public class MinimizeCSSManager extends AbstractMinimizeManager implements Component, Initializable 054{ 055 /** The avalon ROLE */ 056 public static final String ROLE = MinimizeCSSManager.class.getName(); 057 058 private Compiler _jsassCompiler; 059 060 private JSASSResourceURIExtensionPoint _jsassResourceURIExtensionPoint; 061 062 public void initialize() throws Exception 063 { 064 _jsassCompiler = new Compiler(); 065 } 066 067 @Override 068 public void service(ServiceManager smanager) throws ServiceException 069 { 070 super.service(smanager); 071 _jsassResourceURIExtensionPoint = (JSASSResourceURIExtensionPoint) smanager.lookup(JSASSResourceURIExtensionPoint.ROLE); 072 } 073 074 /** 075 * Minimize a CSS string 076 * @param code The CSS code 077 * @param location The CSS location, can be null if no location was determined 078 * @return The minimized CSS 079 * @throws ProcessingException If an error occurred during minimization 080 * @throws IOException If an error occurred while retrieving the source map 081 */ 082 public String minimizeCss(String code, String location) throws ProcessingException, IOException 083 { 084 return minimizeCss(code, location, null, null); 085 } 086 087 /** 088 * Minimize a CSS string 089 * @param code The CSS code 090 * @param location The CSS location, can be null if no location was determined, in which case no source map will be generated 091 * @param sourceMapKey The key to store the source map in cache. Can be null to prevent source map generation 092 * @param lastModified The last modified date of the code, used to assert validity of the source map cache. Can be null 093 * @return The minimized CSS 094 * @throws ProcessingException If an error occurred during minimization 095 * @throws IOException If an error occurred while retrieving the source map 096 */ 097 public String minimizeCss(String code, String location, String sourceMapKey, Long lastModified) throws IOException, ProcessingException 098 { 099 try 100 { 101 boolean withSourceMap = location != null && sourceMapKey != null && lastModified != null; 102 Output output = _minimizeCss(location, "", code, withSourceMap); 103 104 String sourceResult = output.getCss(); 105 106 if (StringUtils.isBlank(sourceResult)) 107 { 108 return ""; 109 } 110 111 if (withSourceMap) 112 { 113 // Source map contains "skins/demo/resources/css/ametys.css" and we need to convert it to "/cms/preview/skins/demo/resources/css/ametys.css" 114 String unfixedSourceMap = super.convertSourceMapURIs(output.getSourceMap(), "/"); 115 String sourceMap = JSASSFixHelper.fixJsassSourceMap(unfixedSourceMap, _proxiedContextPathProvider.getContextPath()); 116 _sourceMapCache.put(sourceMapKey, sourceMap, lastModified); 117 } 118 119 sourceResult = sourceResult.trim(); 120 if (location != null) 121 { 122 String fileName = location.substring(location.lastIndexOf("/") + 1); 123 sourceResult += "\n" + formatSourceMappingURL(fileName + ".map"); 124 } 125 126 return sourceResult; 127 } 128 catch (URISyntaxException e) 129 { 130 throw new ProcessingException("An error occurred while converting source map of " + location, e); 131 } 132 } 133 134 @Override 135 protected String getMinimizedContent(String fileUri, String nestedParentFilesName) 136 { 137 Source cssSource = null; 138 139 try 140 { 141 // example: test.min.css 142 String minimizeFileUri = StringUtils.endsWith(fileUri, ".min.css") ? fileUri : StringUtils.removeEnd(fileUri, ".css") + ".min.css"; 143 URI minimizeUri = new URI(minimizeFileUri); 144 145 String uriToResolve = minimizeUri.isAbsolute() ? minimizeFileUri : "cocoon:/" + org.apache.cocoon.util.NetUtils.normalize(minimizeFileUri); 146 cssSource = _resolver.resolveURI(uriToResolve); 147 148 String originalContent; 149 try (InputStream is = cssSource.getInputStream(); 150 BOMInputStream bomIs = new BOMInputStream(is)) 151 { 152 originalContent = IOUtils.toString(bomIs, "UTF-8"); 153 } 154 155 originalContent = CSSFileHelper.replaceRelativeUri(originalContent, minimizeFileUri, _jsassResourceURIExtensionPoint, _proxiedContextPathProvider.getContextPath(), _proxiedContextPathProvider.getContextPath()); 156 157 StringBuffer result = new StringBuffer(); 158 result.append("/*! File : "); 159 result.append(nestedParentFilesName); 160 result.append(fileUri); 161 result.append(" */\n"); 162 result.append(originalContent); 163 164 return result.toString(); 165 } 166 catch (Exception e) 167 { 168 getLogger().error("Cannot open CSS for aggregation " + fileUri, e); 169 return "/** ERROR " + e.getMessage() + "*/\n"; 170 } 171 finally 172 { 173 _resolver.release(cssSource); 174 } 175 } 176 177 @Override 178 protected String applyMediaToContent(String content, String media) 179 { 180 return "@media " + media + " {\n" + (StringUtils.endsWith(content, "\n") ? (content.substring(0, content.length() - 1) + "}\n") : (content + "}")); 181 } 182 183 private Output _minimizeCss(String cssUri, String nestedParentFilesName, String originalContent, boolean generateSourceMap) throws ProcessingException 184 { 185 try 186 { 187 String content = originalContent; 188 Options options = new Options(); 189 options.setOutputStyle(OutputStyle.COMPRESSED); 190 if (cssUri != null && generateSourceMap) 191 { 192 options.setSourceMapFile(new URI("unused")); // Parameter required to generate the source map, but value is unused 193 options.setOmitSourceMapUrl(true); 194 } 195 Importer cssImporter = new AmetysCssImporter(); 196 options.getImporters().add(cssImporter); 197 198 URI inputPath; 199 URI outputPath; 200 if (cssUri != null) 201 { 202 URI uri = new URI(cssUri); 203 String contextualizedUri = uri.isAbsolute() ? _jsassResourceURIExtensionPoint.resolve(uri.toString()) : uri.toString(); 204 content = CSSFileHelper.replaceRelativeUri(content, contextualizedUri, _jsassResourceURIExtensionPoint, _proxiedContextPathProvider.getContextPath(), _proxiedContextPathProvider.getContextPath()); 205 206 content = _resolveImportUrl(content, nestedParentFilesName + cssUri + " > "); 207 208 inputPath = new URI("." + cssUri); 209 outputPath = _computeMinimizedUri(FilenameUtils.getName(cssUri)); 210 } 211 else 212 { 213 // Minimize the css file as best as possible, due to an unknown location. No source map will be generated 214 options.setSourceMapFile(null); 215 inputPath = new URI("unknown-location"); 216 outputPath = new URI("unknown-location"); 217 } 218 return _jsassCompiler.compileString(content, inputPath, outputPath, options); 219 } 220 catch (CompilationException | URISyntaxException e) 221 { 222 throw new ProcessingException("Unable to Minimize the css file: " + cssUri, e); 223 } 224 } 225 226 227 private URI _computeMinimizedUri(String sourceMapUri) throws URISyntaxException 228 { 229 return new URI(StringUtils.removeEnd(sourceMapUri, ".css") + ".min.css"); 230 } 231 232 private String _resolveImportUrl(String content, String nestedParentFilesName) throws URISyntaxException 233 { 234 StringBuffer sb = new StringBuffer(); 235 236 Matcher importMatcher = CSSFileHelper.IMPORT_PATTERN.matcher(content); 237 238 while (importMatcher.find()) 239 { 240 String cssUrl = importMatcher.group(1); 241 String media = importMatcher.group(2); 242 243 if (cssUrl != null && !cssUrl.startsWith("http://") && !cssUrl.startsWith("https://") && !cssUrl.startsWith("//")) 244 { 245 URI uri = new URI(cssUrl); 246 String uriToCompile = uri.isAbsolute() ? cssUrl : StringUtils.removeStart(cssUrl, _proxiedContextPathProvider.getContextPath()); 247 String importedContent = getMinimizedContent(uriToCompile, nestedParentFilesName); 248 importedContent += " /*! File end : " + nestedParentFilesName + uriToCompile + " */"; 249 if (StringUtils.isNotEmpty(media)) 250 { 251 importedContent = applyMediaToContent(importedContent, media); 252 } 253 importMatcher.appendReplacement(sb, Matcher.quoteReplacement(importedContent)); 254 } 255 } 256 257 importMatcher.appendTail(sb); 258 259 return sb.toString(); 260 } 261 262 @Override 263 protected void addSourceMap(SourceMapGeneratorV3 sourceMapGenerator, int lineCount, String fileContent, String fileUri, String sourceMapUri) 264 { 265 try 266 { 267 String sourceMapContent = null; 268 269 // 1. Get source map from cache 270 String sourceMapKey = fileUri.indexOf('/') > -1 ? new URI(StringUtils.substringBeforeLast(fileUri, "/") + "/" + sourceMapUri).normalize().toString() : sourceMapUri; 271 sourceMapKey = StringUtils.removeStart(sourceMapKey, _proxiedContextPathProvider.getContextPath()); 272 273 Source mapSource = _sourceMapCache.get(sourceMapKey); 274 if (mapSource != null) 275 { 276 try (InputStream is = mapSource.getInputStream()) 277 { 278 sourceMapContent = IOUtils.toString(is, StandardCharsets.UTF_8); 279 } 280 } 281 else 282 { 283 // 2. If not found, get source map content from pipeline .css.map 284 String uriToResolve = new URI(sourceMapKey).isAbsolute() ? sourceMapKey : "cocoon:/" + sourceMapKey; 285 286 try 287 { 288 mapSource = _resolver.resolveURI(uriToResolve); 289 if (mapSource.exists()) 290 { 291 try (InputStream is = mapSource.getInputStream()) 292 { 293 sourceMapContent = IOUtils.toString(is, "UTF-8"); 294 } 295 } 296 } 297 catch (IOException e) 298 { 299 // Nothing 300 } 301 } 302 303 if (sourceMapContent != null) 304 { 305 // 3. aggregate content 306 sourceMapContent = JSASSFixHelper.fixJsassSourceMap(sourceMapContent, _proxiedContextPathProvider.getContextPath()); 307 308 // Add 1 to the line count to jump after the comment "/** File : */" 309 sourceMapGenerator.mergeMapSection(lineCount + 1, 0, sourceMapContent); 310 } 311 else 312 { 313 getLogger().warn("Unable to retrieve source map when aggregating hash file, for sourceMappingURL '" + sourceMapUri + "' of file '" + fileUri + "'"); 314 } 315 } 316 catch (SourceMapParseException | IOException | URISyntaxException | IllegalStateException e) 317 { 318 // IllegalStateException occurs in "sourceMapGenerator.mergeMapSection" when a previous invalid source map offsets the line count too much 319 getLogger().error("Unable to merge source map of '" + fileUri + "' to the CSS minimized source map", e); 320 } 321 } 322 323 @Override 324 protected boolean isSourceMappingURLLine(String line) 325 { 326 return line != null && line.startsWith("/*# sourceMappingURL=") && line.endsWith(" */"); 327 } 328 329 @Override 330 protected String getSourceMappingURL(String line) 331 { 332 return line.substring("/*# sourceMappingURL=".length(), line.length() - " */".length()).trim(); 333 } 334 335 @Override 336 protected String removeSourceMappingURLLine(String content) 337 { 338 return content.substring(0, content.lastIndexOf("/*# sourceMappingURL=")); 339 } 340 341 @Override 342 protected String formatSourceMappingURL(String sourceMapName) 343 { 344 return "/*# sourceMappingURL=" + sourceMapName + " */\n"; 345 } 346 347 348}