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}