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.sass; 017 018import java.io.IOException; 019import java.net.URI; 020import java.net.URISyntaxException; 021import java.util.List; 022import java.util.stream.Collectors; 023 024import org.apache.avalon.framework.activity.Initializable; 025import org.apache.avalon.framework.component.Component; 026import org.apache.avalon.framework.context.Context; 027import org.apache.avalon.framework.context.ContextException; 028import org.apache.avalon.framework.context.Contextualizable; 029import org.apache.avalon.framework.service.ServiceException; 030import org.apache.avalon.framework.service.ServiceManager; 031import org.apache.avalon.framework.service.Serviceable; 032import org.apache.cocoon.components.ContextHelper; 033import org.apache.commons.io.FilenameUtils; 034import org.apache.commons.lang3.StringUtils; 035import org.apache.excalibur.source.SourceResolver; 036 037import org.ametys.core.minimize.SourceMapCache; 038import org.ametys.core.minimize.css.JSASSFixHelper; 039import org.ametys.core.resources.ProxiedContextPathProvider; 040import org.ametys.plugins.core.ui.resources.css.CSSFileHelper; 041import org.ametys.plugins.core.ui.resources.css.JSASSResourceURIExtensionPoint; 042import org.ametys.plugins.core.ui.resources.css.sass.SassFunctionsProvider; 043import org.ametys.plugins.core.ui.resources.css.sass.SassFunctionsProviderExtensionPoint; 044import org.ametys.runtime.plugin.component.AbstractLogEnabled; 045 046import io.bit3.jsass.CompilationException; 047import io.bit3.jsass.Compiler; 048import io.bit3.jsass.Options; 049import io.bit3.jsass.Output; 050import io.bit3.jsass.OutputStyle; 051 052/** 053 * Minimize manager for CSS files 054 */ 055public class MinimizeSassManager extends AbstractLogEnabled implements Component, Serviceable, Initializable, Contextualizable 056{ 057 /** The avalon ROLE */ 058 public static final String ROLE = MinimizeSassManager.class.getName(); 059 private Compiler _jsassCompiler; 060 061 private JSASSResourceURIExtensionPoint _jsassResourceURIExtensionPoint; 062 063 private SourceResolver _resolver; 064 065 private List<SassFunctionsProvider> _sassFunctionsProviders; 066 067 private SourceMapCache _sourceMapCache; 068 069 private ProxiedContextPathProvider _proxiedContextPathProvider; 070 071 private Context _context; 072 073 public void initialize() throws Exception 074 { 075 _jsassCompiler = new Compiler(); 076 } 077 078 public void contextualize(Context context) throws ContextException 079 { 080 _context = context; 081 } 082 083 @Override 084 public void service(ServiceManager smanager) throws ServiceException 085 { 086 _jsassResourceURIExtensionPoint = (JSASSResourceURIExtensionPoint) smanager.lookup(JSASSResourceURIExtensionPoint.ROLE); 087 _resolver = (SourceResolver) smanager.lookup(SourceResolver.ROLE); 088 SassFunctionsProviderExtensionPoint sassFunctionsProviderEP = (SassFunctionsProviderExtensionPoint) smanager.lookup(SassFunctionsProviderExtensionPoint.ROLE); 089 _sassFunctionsProviders = sassFunctionsProviderEP.getExtensionsIds().stream().map(id -> sassFunctionsProviderEP.getExtension(id)).collect(Collectors.toList()); 090 _sourceMapCache = (SourceMapCache) smanager.lookup(SourceMapCache.ROLE); 091 _proxiedContextPathProvider = (ProxiedContextPathProvider) smanager.lookup(ProxiedContextPathProvider.ROLE); 092 } 093 094 095 /** 096 * Compile a sass file. 097 * The source map of the file is generated and stored in the source map cache 098 * @param content The content of the sass file 099 * @param sassLocation The location of the sass file, e.g. plugin:core-ui:/resources/example/test.scss 100 * @param location The location of the compiled css, e.g. plugin:core-ui:/resources/example/test.css or plugin:core-ui:/resources/example/test.min.css 101 * @param minimize Minimize the output 102 * @param lastModified The date of last modification 103 * @return The content of the compiled, and optionally minimized, sass file 104 * @throws URISyntaxException If an exception occurred 105 * @throws CompilationException If an exception occurred 106 * @throws IOException If an exception occurred 107 */ 108 public String compileCss(String content, String sassLocation, String location, boolean minimize, long lastModified) throws URISyntaxException, CompilationException, IOException 109 { 110 String externalContextPath = _proxiedContextPathProvider.getContextPath(); 111 112 String contextualizedUri = null; 113 try 114 { 115 // example: /plugins/core-ui/resources/example/test.scss 116 contextualizedUri = _jsassResourceURIExtensionPoint.resolve(sassLocation); 117 } 118 catch (URISyntaxException e) 119 { 120 getLogger().error("Unable to resolve URI location because URI syntax '" + location + "' is not supported", e); 121 } 122 123 if (contextualizedUri != null && contextualizedUri.contains("://")) 124 { 125 throw new URISyntaxException(contextualizedUri, "Unable to compile Sass file, unsupported URI starts with unknown protocol"); 126 } 127 128 // example: /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.scss 129 URI uri = contextualizedUri != null ? new URI(externalContextPath + contextualizedUri) : null; 130 131 // example: test.css or test.min.css 132 String locationFilename = FilenameUtils.getName(location); 133 Output compiledString = _compileCss(content, locationFilename, uri, minimize, FilenameUtils.getExtension(sassLocation)); 134 String compileResult = compiledString.getCss(); 135 136 if (uri != null) 137 { 138 String result = CSSFileHelper.replaceRelativeResourcesUri(compileResult, contextualizedUri, _jsassResourceURIExtensionPoint, externalContextPath); 139 if (!result.equals(compileResult)) 140 { 141 getLogger().warn("Relative path found in '{}'. Relative paths inside SASS files need to be avoided, as it will offset the source mapping. Please use functions ('pluginUrl()' or similar helpers) instead.", uri.toString()); 142 } 143 144 String sourceMap = compiledString.getSourceMap(); 145 // example: /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.scss.map or /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.css.map 146 String sourceMapName = !minimize ? uri.toString() + ".map" : StringUtils.removeEnd(uri.toString(), ".scss") + ".css.map"; 147 148 sourceMap = JSASSFixHelper.fixJsassSourceMap(sourceMap, externalContextPath); 149 150 // example: /plugins/core-ui/resources/example/test.scss.map or /plugins/core-ui/resources/example/test.css.map 151 String cacheKey = StringUtils.removeStart(sourceMapName, externalContextPath); 152 _sourceMapCache.put(cacheKey, sourceMap, lastModified); 153 154 result += "/*# sourceMappingURL=" + FilenameUtils.getName(sourceMapName) + " */\n"; 155 return result; 156 } 157 158 return compileResult; 159 } 160 161 162 /** 163 * Generate the source map of compiled sass file. 164 * The source map of the file is generated and stored in the source map cache 165 * @param content The content of the file 166 * @param location The location of the source map file, e.g. plugin:core-ui:/resources/example/test.scss.map or plugin:core-ui:/resources/example/test.css.map 167 * @param extension The extension of the sass file, e.g. scss or sass 168 * @param internalContextPath The internal context path 169 * @param externalContextPath The external context path 170 * @param minimize Minimize the output 171 * @param lastModified The date of last modification 172 * @return The content of the source map, or null if no source map was generated 173 * @throws URISyntaxException If an exception occurred 174 * @throws CompilationException If an exception occurred 175 * @throws IOException If an exception occurred 176 */ 177 public String generateCssSourceMap(String content, String location, String extension, String internalContextPath, String externalContextPath, boolean minimize, long lastModified) throws URISyntaxException, CompilationException, IOException 178 { 179 String contextualizedUri = null; 180 try 181 { 182 // example: /plugins/core-ui/resources/example/test.scss or /plugins/core-ui/resources/example/test.css 183 contextualizedUri = _jsassResourceURIExtensionPoint.resolve(StringUtils.removeEnd(location, ".map")); 184 } 185 catch (URISyntaxException e) 186 { 187 getLogger().error("Unable to resolve URI location because URI syntax '" + location + "' is not supported", e); 188 return null; // No source map generated 189 } 190 191 if (contextualizedUri.contains("://")) 192 { 193 throw new URISyntaxException(contextualizedUri, "Unable to compile Sass file, unsupported URI '" + location + "' contains unknown protocol"); 194 } 195 196 // example: /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.scss 197 URI sassUri = new URI(externalContextPath + (!minimize ? contextualizedUri : StringUtils.removeEnd(contextualizedUri, ".css") + "." + extension)); 198 199 // example: test.css 200 String locationFilename = FilenameUtils.getName(StringUtils.removeEnd(sassUri.toString(), "." + extension) + (!minimize ? ".css" : ".min.css")); 201 Output compiledString = _compileCss(content, locationFilename, sassUri, minimize, extension); 202 203 String sourceMap = compiledString.getSourceMap(); 204 // example: /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.scss or /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.css 205 URI uri = new URI(externalContextPath + contextualizedUri); 206 // example: /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.scss.map or /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.css.map 207 String sourceMapName = !minimize ? uri.toString() + ".map" : StringUtils.removeEnd(uri.toString(), "." + extension) + ".css.map"; 208 209 sourceMap = JSASSFixHelper.fixJsassSourceMap(sourceMap, externalContextPath); 210 211 // example: /plugins/core-ui/resources/example/test.scss.map or /plugins/core-ui/resources/example/test.css.map 212 String cacheKey = StringUtils.removeStart(sourceMapName, externalContextPath); 213 _sourceMapCache.put(cacheKey, sourceMap, lastModified); 214 215 return sourceMap; 216 } 217 218 /** 219 * Compile a SCSS and output the JSASS result containing the content and source map 220 * @param content The content of the file 221 * @param fileName The name of the compiled sass file, e.g. test.css or test.min.css 222 * @param uri The contextualized uri of the sass file, e.g. /EXTERNAL_CONTEXT/plugins/core-ui/resources/example/test.scss 223 * @param minimize Minimize the output 224 * @param extension The sass extension such as scss or sass 225 * @return The output of the compilation 226 * @throws URISyntaxException if an error occurred with the uri syntax 227 * @throws CompilationException if an error occured when compiling the sass 228 */ 229 private Output _compileCss(String content, String fileName, URI uri, boolean minimize, String extension) throws URISyntaxException, CompilationException 230 { 231 String externalContextPath = _proxiedContextPathProvider.getContextPath(); 232 233 Options options = new Options(); 234 AmetysScssImporter importer = new AmetysScssImporter(ContextHelper.getRequest(_context).getContextPath(), externalContextPath, _resolver, _jsassResourceURIExtensionPoint); 235 options.getImporters().add(importer); 236 options.getFunctionProviders().addAll(_sassFunctionsProviders); 237 238 if (minimize) 239 { 240 options.setOutputStyle(OutputStyle.COMPRESSED); 241 } 242 243 URI inputPath; 244 URI outputPath; 245 if (fileName != null && uri != null) 246 { 247 options.setSourceMapFile(new URI("unused")); // Parameter required to generate the source map, but value is unused 248 options.setOmitSourceMapUrl(true); 249 250 // Transform the absolute path in relative path "/contextPath/plugins" becomes "./contextPath/plugins" to prevent jsass messing with it 251 // The relative path is transformed back into absolute in post-processing of the source map 252 inputPath = new URI("." + uri.toString()); 253 outputPath = new URI(fileName); 254 } 255 else 256 { 257 // Compile the sass file as best as possible, due to an unknown location. No source map will be generated 258 options.setSourceMapFile(null); 259 inputPath = new URI("unknown-location"); 260 outputPath = new URI("unknown-location"); 261 } 262 263 return _jsassCompiler.compileString(content, inputPath, outputPath, options); 264 } 265}