001/*
002 *  Copyright 2016 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 */
016
017package org.ametys.plugins.core.ui.resources;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.net.MalformedURLException;
022import java.net.URI;
023import java.net.URISyntaxException;
024import java.nio.charset.StandardCharsets;
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.HashMap;
028import java.util.LinkedList;
029import java.util.List;
030import java.util.Map;
031import java.util.regex.Matcher;
032import java.util.regex.Pattern;
033import java.util.stream.Collectors;
034
035import org.apache.avalon.framework.parameters.Parameters;
036import org.apache.avalon.framework.service.ServiceException;
037import org.apache.avalon.framework.service.ServiceManager;
038import org.apache.cocoon.ProcessingException;
039import org.apache.commons.io.FilenameUtils;
040import org.apache.commons.io.IOUtils;
041import org.apache.commons.lang3.StringUtils;
042import org.apache.excalibur.source.Source;
043import org.apache.excalibur.source.SourceNotFoundException;
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.importer.Import;
050import io.bit3.jsass.importer.Importer;
051
052/**
053 * Reader for SASS files, compile them on the fly into CSS files.
054 */
055public class SassResourceHandler extends AbstractCompiledResourceHandler
056{
057    private static final Pattern __IMPORT_PATTERN = Pattern.compile("^\\s*@import\\s+(?:(?:url)?\\(?\\s*[\"']?)([^)\"']+)[\"']?\\)?\\s*;?\\s*$", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
058    private static final String[] __SASS_EXTENSION = new String[] {".scss", ".sass"};
059
060    private Compiler _jsassCompiler;
061
062    private List<SassFunctionsProvider> _sassFunctionsProviders;
063    
064    @Override
065    public void service(ServiceManager serviceManager) throws ServiceException
066    {
067        super.service(serviceManager);
068        SassFunctionsProviderExtensionPoint sassFunctionsProviderEP = (SassFunctionsProviderExtensionPoint) serviceManager.lookup(SassFunctionsProviderExtensionPoint.ROLE);
069        _sassFunctionsProviders = sassFunctionsProviderEP.getExtensionsIds().stream().map(id -> sassFunctionsProviderEP.getExtension(id)).collect(Collectors.toList());
070    }
071    
072    @Override
073    public int getPriority()
074    {
075        return MIN_PRIORITY + 1000;
076    }
077    
078    @Override
079    public boolean isSupported(String source)
080    {
081        if (!super.isSupported(source))
082        {
083            return false;
084        }
085        
086        String lcSource = StringUtils.lowerCase(source);
087        if (lcSource.endsWith(".css"))
088        {
089            String sourceWithExt = StringUtils.substringBeforeLast(source, ".css");
090            for (String ext : __SASS_EXTENSION)
091            {
092                Source src = null;
093                try
094                {
095                    src = _resolver.resolveURI(sourceWithExt + ext);
096                    if (src.exists())
097                    {
098                        return true;
099                    }
100                }
101                catch (IOException e)
102                {
103                    // Nothing
104                }
105                finally 
106                {
107                    _resolver.release(src);
108                }
109            }
110            
111            // No .scss or .sass file found
112            return false;
113        }
114        else
115        {
116            return true;
117        }
118    }
119    
120    @Override
121    protected Source getCompiledSource(String location) throws MalformedURLException, IOException
122    {
123        if (location.toLowerCase().endsWith(".css"))
124        {
125            String sourceWithExt = StringUtils.substringBeforeLast(location, ".css");
126            for (String ext : __SASS_EXTENSION)
127            {
128                Source src = _resolver.resolveURI(sourceWithExt + ext);
129                if (src.exists())
130                {
131                    return src;
132                }
133            }
134        }
135        
136        return _resolver.resolveURI(location);
137    }
138    
139    @Override
140    public Source setup(String location, Parameters par) throws ProcessingException, IOException
141    {
142        Source source = super.setup(location, par);
143        
144        _jsassCompiler = new Compiler();
145        
146        return source;
147    }
148
149    @Override
150    public String compileResource(Source resource) throws IOException, ProcessingException
151    {
152        Output compiledString = null;
153        Options options = new Options();
154        AmetysSassImporter sassImporter = new AmetysSassImporter();
155        options.getImporters().add(sassImporter);
156        options.getFunctionProviders().addAll(_sassFunctionsProviders);
157        
158        try (InputStream is = resource.getInputStream())
159        {
160            URI uri = new URI(resource.getURI());
161            sassImporter.registerValidURI(uri.toString());
162            String sassContent = IOUtils.toString(is, StandardCharsets.UTF_8);
163            compiledString = _jsassCompiler.compileString(sassContent, uri, uri, options);
164        }
165        catch (CompilationException | URISyntaxException e)
166        {
167            throw new ProcessingException("Unable to compile the SASS file: " + resource.getURI(), e);
168        }
169        
170        return compiledString.getCss();
171    }
172    
173    
174    @Override
175    protected List<String> getDependenciesList(Source inputSource)
176    {
177        List<String> result = new ArrayList<>();
178        
179        try (InputStream is = inputSource.getInputStream())
180        {
181            String previous = inputSource.getURI();
182            String content = IOUtils.toString(is, StandardCharsets.UTF_8);
183            
184            Matcher matcher = __IMPORT_PATTERN.matcher(content);
185            
186            while (matcher.find())
187            {
188                String cssUrl = matcher.group(1);
189                
190                if (!StringUtils.contains(cssUrl, "http://") && !StringUtils.contains(cssUrl, "https://")) 
191                {
192                    URI currentUri = new URI(cssUrl);
193                    if (!currentUri.isAbsolute())
194                    {
195                        currentUri = new URI(FilenameUtils.getFullPath(previous.toString()) + cssUrl);
196                    }
197            
198                    Source importSource = _getImportSource(currentUri.toString());
199                    
200                    result.add(importSource.getURI().toString());
201                }
202            }
203        }
204        catch (IOException | URISyntaxException e)
205        {
206            getLogger().warn("Invalid " + inputSource.getURI(), e);
207        }
208        
209        return result;
210    }
211    
212    @Override
213    public String getMimeType(Source source, Parameters par)
214    {
215        return "text/css";
216    }
217    
218    /**
219     * Get the Sass source from the current Uri
220     * @param currentUri The URI
221     * @return The Sass source
222     * @throws URISyntaxException If the Uri does not match a source
223     * @throws IOException If an error occurred
224     */
225    protected Source _getImportSource(String currentUri) throws URISyntaxException, IOException
226    {
227        List<String> uriMatching = new ArrayList<>();
228        uriMatching.add(currentUri);
229        
230        // extension is optional
231        uriMatching.add(currentUri + ".scss");
232        uriMatching.add(currentUri + ".sass");
233        uriMatching.add(currentUri + ".css");
234        
235        // add an underscore prefix to the file name for sass partial imports
236        String name = FilenameUtils.getName(currentUri);
237        String partialUri = currentUri.substring(0, currentUri.length() - name.length()) + "_" + name;
238        uriMatching.add(partialUri);
239        uriMatching.add(partialUri + ".scss");
240        uriMatching.add(partialUri + ".sass");
241        uriMatching.add(partialUri + ".css");
242        
243        for (String uri : uriMatching)
244        {
245            try
246            {
247                Source importSource = _resolver.resolveURI(uri);
248                if (importSource.exists())
249                {
250                    return importSource;
251                }
252            }
253            catch (SourceNotFoundException e)
254            {
255                // source does not exists. Do nothing
256            }
257        }
258        
259        throw new URISyntaxException(currentUri, "Unable to resolve SASS import, no matching source found");
260    }
261
262    /**
263     * Sass Importer which can resolve Ametys resources
264     */
265    private class AmetysSassImporter implements Importer 
266    {
267        private Map<String, String> _validURIsRegistered;
268    
269        /**
270         * Default constructor for the Ametys SassImporter. Provides information for resolving imported resources.
271         */
272        public AmetysSassImporter()
273        {
274            _validURIsRegistered = new HashMap<>();
275        }
276        
277        /**
278         * Because Libsass does not fully respect URI schemes and can sometimes corrupt URIs, this method can be used to provide a URI known as valid.
279         * When resolving the URI received from Libsass, it will first be checked against valid URIs, to prevent common corruption.
280         * Example of known URI corruption : scheme of URI "plugin:*://" will be transformed into "plugin:*:/"  
281         * @param validUri A valid URI
282         */
283        public void registerValidURI(String validUri)
284        {
285            if (StringUtils.contains(validUri, "://"))
286            {
287                String schema =  validUri.substring(0, validUri.indexOf("://"));
288                String corruptedUri = schema + ":/" + StringUtils.removeStart(validUri, schema + "://");
289                _validURIsRegistered.put(corruptedUri, validUri);
290            }
291        }
292        
293        public Collection<Import> apply(String url, Import previous)
294        {
295            if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//"))
296            {
297                // returning null keeps the original @import mention
298                return null;
299            }
300            
301            List<Import> list = new LinkedList<>();
302            URI currentUri = null;
303            
304            try
305            {
306                URI importUrl = new URI(url);
307                if (importUrl.isAbsolute())
308                {
309                    currentUri = importUrl;
310                    this.registerValidURI(currentUri.toString());
311                }
312                else
313                {
314                    if (_validURIsRegistered.containsKey(previous.getAbsoluteUri().toString()))
315                    {
316                        // URI replaced with the registered matching valid URI, because of potential URI corruption.
317                        currentUri = new URI(FilenameUtils.getFullPath(_validURIsRegistered.get(previous.getAbsoluteUri().toString())) + url);
318                    }
319                    else
320                    {
321                        currentUri = new URI(FilenameUtils.getFullPath(previous.getAbsoluteUri().toString()) + url);
322                    }
323                }
324                
325                Source importSource = _getImportSource(currentUri.toString());
326
327                if (importSource.getURI().endsWith(".scss") 
328                        || importSource.getURI().endsWith(".sass") 
329                        || importSource.getURI().endsWith(".css") && !currentUri.toString().endsWith(".css")) // An url ending at the origin by .css should stay an import at the end (while a .css added automatically should be resolved)
330                {
331                    String importText = IOUtils.toString(importSource.getInputStream(), "UTF-8");
332                    list.add(new Import(currentUri, currentUri, importText));
333                }
334                else
335                {
336                    // returning null keeps the original @import mention
337                    return null;
338                }
339            }
340            catch (URISyntaxException e) 
341            {
342                throw new RuntimeException(e);
343            }
344            catch (IOException e)
345            {
346                throw new RuntimeException(e);
347            }
348    
349            return list;
350        }
351
352
353    }
354}