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