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.css.less;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.OutputStream;
022import java.io.UnsupportedEncodingException;
023import java.net.MalformedURLException;
024import java.net.URI;
025import java.net.URISyntaxException;
026import java.nio.charset.StandardCharsets;
027import java.util.ArrayList;
028import java.util.List;
029import java.util.Map;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032
033import org.apache.avalon.framework.parameters.Parameters;
034import org.apache.cocoon.ProcessingException;
035import org.apache.commons.io.FilenameUtils;
036import org.apache.commons.io.IOUtils;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.excalibur.source.Source;
039import org.apache.excalibur.source.SourceResolver;
040
041import org.ametys.plugins.core.ui.resources.AbstractCompiledResourceHandler;
042
043import com.github.sommeri.less4j.Less4jException;
044import com.github.sommeri.less4j.LessCompiler.CompilationResult;
045import com.github.sommeri.less4j.LessSource;
046import com.github.sommeri.less4j.core.DefaultLessCompiler;
047
048/**
049 * Reader for LESS files, compile them on the fly into CSS files.
050 */
051public class LessResourceHandler extends AbstractCompiledResourceHandler
052{
053    private static final Pattern __IMPORT_PATTERN = Pattern.compile("^@import\\b\\s*(?:(?:url)?\\(?\\s*[\"']?)([^)\"']*)[\"']?\\)?\\s*;?$", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
054    private static final String[] __LESS_EXTENSION = new String[] {".less"};
055    
056    private DefaultLessCompiler _defaultLessCompiler;
057    
058    @Override
059    public int getPriority()
060    {
061        return MIN_PRIORITY + 1000;
062    }
063    
064    @Override
065    public boolean isSupported(String source)
066    {
067        if (!super.isSupported(source))
068        {
069            return false;
070        }
071        
072        String lcSource = StringUtils.lowerCase(source);
073        if (lcSource.endsWith(".css"))
074        {
075            String sourceWithExt = StringUtils.substringBeforeLast(source, ".css");
076            for (String ext : __LESS_EXTENSION)
077            {
078                Source src = null;
079                try
080                {
081                    src = _resolver.resolveURI(sourceWithExt + ext);
082                    if (src.exists())
083                    {
084                        return true;
085                    }
086                }
087                catch (IOException e)
088                {
089                    // Nothing
090                }
091                finally 
092                {
093                    _resolver.release(src);
094                }
095                
096            }
097            
098            // No .less file found
099            return false;
100        }
101        else
102        {
103            return true;
104        }
105        
106    }
107    
108    @Override
109    protected Source getSourceToCompile(String location, Map<String, Object> additionalParameters) throws MalformedURLException, IOException
110    {
111        if (location.toLowerCase().endsWith(".css"))
112        {
113            String sourceWithExt = StringUtils.substringBeforeLast(location, ".css");
114            for (String ext : __LESS_EXTENSION)
115            {
116                Source src = _resolver.resolveURI(sourceWithExt + ext);
117                if (src.exists())
118                {
119                    return src;
120                }
121            }
122        }
123        
124        return _resolver.resolveURI(location);
125    }
126    
127    @Override
128    public Source setup(String location, Map objectModel, Parameters par, Map<String, Object> additionalParameters) throws ProcessingException, IOException
129    {
130        Source source = super.setup(location, objectModel, par, additionalParameters);
131        
132        _defaultLessCompiler = new DefaultLessCompiler();
133        
134        return source;
135    }
136        
137    public void generateResource(Source source, OutputStream out, Map objectModel, Parameters parameters, Map<String, Object> additionalParameters) throws IOException, ProcessingException
138    {
139        CompilationResult result = null;
140        
141        try (InputStream is = source.getInputStream())
142        {
143            String lessContent = IOUtils.toString(is, StandardCharsets.UTF_8);
144            AmetysLessSource stringSource = new AmetysLessSource(_resolver, lessContent, new URI(source.getURI()));
145            result = _defaultLessCompiler.compile(stringSource);
146        }
147        catch (Less4jException e)
148        {
149            throw new ProcessingException("Unable to compile the LESS file : " + source.getURI(), e);
150        }
151        catch (URISyntaxException e)
152        {
153            throw new ProcessingException("Unable to process LESS File, invalid uri : " + source.getURI(), e);
154        }
155        
156        String resultString = result == null ? null : result.getCss();
157        IOUtils.write(resultString, out, StandardCharsets.UTF_8);
158    }
159    
160    @Override
161    protected List<String> getDependenciesList(Source inputSource)
162    {
163        List<String> result = new ArrayList<>();
164        
165        try (InputStream is = inputSource.getInputStream())
166        {
167            String content = IOUtils.toString(is, StandardCharsets.UTF_8);
168            
169            Matcher matcher = __IMPORT_PATTERN.matcher(content);
170            
171            while (matcher.find())
172            {
173                String cssUrl = matcher.group(1);
174                
175                if (!StringUtils.contains(cssUrl, "http://") && !StringUtils.contains(cssUrl, "https://")) 
176                {
177                    if (!StringUtils.endsWith(cssUrl, ".css") && !StringUtils.endsWith(cssUrl, ".less"))
178                    {
179                        cssUrl += ".less";
180                    }
181                    
182                    result.add(cssUrl);
183                }
184            }
185        }
186        catch (IOException e)
187        {
188            getLogger().warn("Invalid content when listing dependencies for file " + inputSource.getURI(), e);
189        }
190        
191        return result;
192    }
193
194
195    /**
196     * LessSource definition for Ametys Resources
197     */
198    private static class AmetysLessSource extends LessSource
199    {
200        private String _lessContent;
201        private String _name;
202        private URI _sourceUri;
203        private SourceResolver _sResolver;
204    
205        /**
206         * Default constructor for Ametys less source
207         * @param sourceResolver The Source Resolver
208         * @param lessContent The content of the less source
209         * @param uri The uri of the less source
210         */
211        public AmetysLessSource(SourceResolver sourceResolver, String lessContent, URI uri)
212        {
213            _sResolver = sourceResolver;
214            _lessContent = lessContent;
215            _sourceUri = uri;
216        }
217    
218        @Override
219        public LessSource relativeSource(String relativePath) throws FileNotFound, CannotReadFile, StringSourceException
220        {
221            try
222            {
223                URI relativeSourceUri = new URI(relativePath);
224                if (!relativeSourceUri.isAbsolute())
225                {
226                    relativeSourceUri = new URI(FilenameUtils.getFullPath(_sourceUri.toString()) + relativePath);
227                }
228                
229                // SASS files can be .sass or .scss
230                Source importSource = null;
231                
232                importSource = _sResolver.resolveURI(relativeSourceUri.toString());
233    
234                String importText = IOUtils.toString(importSource.getInputStream(), "UTF-8");
235                return new AmetysLessSource(_sResolver, importText, relativeSourceUri);
236            }
237            catch (Exception e)
238            {
239                throw new RuntimeException("Unable to process LESS File : " + _sourceUri + ", invalid import : " + relativePath, e);
240            }
241        }
242    
243        @Override
244        public String getContent() throws FileNotFound, CannotReadFile
245        {
246            return _lessContent;
247        }
248    
249        @Override
250        public byte[] getBytes() throws FileNotFound, CannotReadFile
251        {
252            try
253            {
254                return _lessContent.getBytes("UTF-8");
255            }
256            catch (UnsupportedEncodingException e)
257            {
258                throw new CannotReadFile();
259            }
260        }
261        
262        @Override
263        public URI getURI()
264        {
265            return _sourceUri;
266        }
267        
268        @Override
269        public String getName()
270        {
271            return _name;
272        }
273    
274    }
275
276    @Override
277    public String getMimeType(Source source, Parameters par)
278    {
279        return "text/css";
280    }
281
282}