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;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.net.URI;
021import java.net.URISyntaxException;
022import java.nio.charset.StandardCharsets;
023import java.util.regex.Matcher;
024
025import org.apache.avalon.framework.activity.Initializable;
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.cocoon.ProcessingException;
030import org.apache.commons.io.FilenameUtils;
031import org.apache.commons.io.IOUtils;
032import org.apache.commons.io.input.BOMInputStream;
033import org.apache.commons.lang3.StringUtils;
034import org.apache.excalibur.source.Source;
035
036import org.ametys.core.minimize.AbstractMinimizeManager;
037import org.ametys.plugins.core.ui.resources.css.CSSFileHelper;
038import org.ametys.plugins.core.ui.resources.css.JSASSResourceURIExtensionPoint;
039
040import com.google.debugging.sourcemap.SourceMapGeneratorV3;
041import com.google.debugging.sourcemap.SourceMapParseException;
042
043import io.bit3.jsass.CompilationException;
044import io.bit3.jsass.Compiler;
045import io.bit3.jsass.Options;
046import io.bit3.jsass.Output;
047import io.bit3.jsass.OutputStyle;
048import io.bit3.jsass.importer.Importer;
049
050/**
051 * Minimize manager for CSS files
052 */
053public class MinimizeCSSManager extends AbstractMinimizeManager implements Component, Initializable
054{
055    /** The avalon ROLE */
056    public static final String ROLE = MinimizeCSSManager.class.getName();
057    
058    private Compiler _jsassCompiler;
059
060    private JSASSResourceURIExtensionPoint _jsassResourceURIExtensionPoint;
061
062    public void initialize() throws Exception
063    {
064        _jsassCompiler = new Compiler();
065    }
066    
067    @Override
068    public void service(ServiceManager smanager) throws ServiceException
069    {
070        super.service(smanager);
071        _jsassResourceURIExtensionPoint = (JSASSResourceURIExtensionPoint) smanager.lookup(JSASSResourceURIExtensionPoint.ROLE);
072    }
073    
074    /**
075     * Minimize a CSS string
076     * @param code The CSS code
077     * @param location The CSS location, can be null if no location was determined
078     * @return The minimized CSS
079     * @throws ProcessingException If an error occurred during minimization
080     * @throws IOException If an error occurred while retrieving the source map
081     */
082    public String minimizeCss(String code, String location)  throws ProcessingException, IOException
083    {
084        return minimizeCss(code, location, null, null);
085    }
086
087    /**
088     * Minimize a CSS string
089     * @param code The CSS code
090     * @param location The CSS location, can be null if no location was determined, in which case no source map will be generated
091     * @param sourceMapKey The key to store the source map in cache. Can be null to prevent source map generation
092     * @param lastModified The last modified date of the code, used to assert validity of the source map cache. Can be null
093     * @return The minimized CSS
094     * @throws ProcessingException If an error occurred during minimization
095     * @throws IOException If an error occurred while retrieving the source map
096     */
097    public String minimizeCss(String code, String location, String sourceMapKey, Long lastModified) throws IOException, ProcessingException 
098    {
099        try
100        {
101            boolean withSourceMap = location != null && sourceMapKey != null && lastModified != null;
102            Output output = _minimizeCss(location, "", code, withSourceMap);
103            
104            String sourceResult = output.getCss();
105            
106            if (StringUtils.isBlank(sourceResult))
107            {
108                return "";
109            }
110            
111            if (withSourceMap)
112            {
113                // Source map contains "skins/demo/resources/css/ametys.css" and we need to convert it to "/cms/preview/skins/demo/resources/css/ametys.css"
114                String unfixedSourceMap = super.convertSourceMapURIs(output.getSourceMap(), "/");
115                String sourceMap = JSASSFixHelper.fixJsassSourceMap(unfixedSourceMap, _proxiedContextPathProvider.getContextPath());
116                _sourceMapCache.put(sourceMapKey, sourceMap, lastModified);
117            }
118            
119            sourceResult = sourceResult.trim();
120            if (location != null)
121            {
122                String fileName = location.substring(location.lastIndexOf("/") + 1);
123                sourceResult += "\n" + formatSourceMappingURL(fileName + ".map");
124            }
125            
126            return sourceResult;
127        }
128        catch (URISyntaxException e)
129        {
130            throw new ProcessingException("An error occurred while converting source map of " + location, e);
131        }
132    }
133
134    @Override
135    protected String getMinimizedContent(String fileUri, String nestedParentFilesName)
136    {
137        Source cssSource = null;
138        
139        try
140        {
141            // example: test.min.css
142            String minimizeFileUri = StringUtils.endsWith(fileUri, ".min.css") ? fileUri : StringUtils.removeEnd(fileUri, ".css") + ".min.css";
143            URI minimizeUri = new URI(minimizeFileUri);
144            
145            String uriToResolve = minimizeUri.isAbsolute() ? minimizeFileUri : "cocoon:/" + org.apache.cocoon.util.NetUtils.normalize(minimizeFileUri);
146            cssSource = _resolver.resolveURI(uriToResolve);
147            
148            String originalContent;
149            try (InputStream is = cssSource.getInputStream();
150                 BOMInputStream bomIs = new BOMInputStream(is))
151            {
152                originalContent = IOUtils.toString(bomIs, "UTF-8");
153            }
154            
155            originalContent = CSSFileHelper.replaceRelativeUri(originalContent, minimizeFileUri, _jsassResourceURIExtensionPoint,  _proxiedContextPathProvider.getContextPath(),  _proxiedContextPathProvider.getContextPath());
156            
157            StringBuffer result = new StringBuffer();
158            result.append("/*! File : ");
159            result.append(nestedParentFilesName);
160            result.append(fileUri);
161            result.append(" */\n");
162            result.append(originalContent);
163            
164            return result.toString();
165        }
166        catch (Exception e)
167        {
168            getLogger().error("Cannot open CSS for aggregation " + fileUri, e);
169            return "/** ERROR " + e.getMessage() + "*/\n";
170        }
171        finally
172        {
173            _resolver.release(cssSource);
174        }
175    }
176    
177    @Override
178    protected String applyMediaToContent(String content, String media)
179    {
180        return "@media " + media + " {\n" + (StringUtils.endsWith(content, "\n") ? (content.substring(0, content.length() - 1) + "}\n") : (content + "}"));
181    }
182
183    private Output _minimizeCss(String cssUri, String nestedParentFilesName, String originalContent, boolean generateSourceMap) throws ProcessingException
184    {
185        try
186        {
187            String content = originalContent;
188            Options options = new Options();
189            options.setOutputStyle(OutputStyle.COMPRESSED);
190            if (cssUri != null && generateSourceMap)
191            {
192                options.setSourceMapFile(new URI("unused")); // Parameter required to generate the source map, but value is unused
193                options.setOmitSourceMapUrl(true);
194            }
195            Importer cssImporter = new AmetysCssImporter();
196            options.getImporters().add(cssImporter);
197
198            URI inputPath;
199            URI outputPath;
200            if (cssUri != null)
201            {
202                URI uri = new URI(cssUri);
203                String contextualizedUri = uri.isAbsolute() ? _jsassResourceURIExtensionPoint.resolve(uri.toString()) : uri.toString();
204                content = CSSFileHelper.replaceRelativeUri(content, contextualizedUri, _jsassResourceURIExtensionPoint, _proxiedContextPathProvider.getContextPath(), _proxiedContextPathProvider.getContextPath());
205    
206                content = _resolveImportUrl(content, nestedParentFilesName + cssUri + " > ");
207                
208                inputPath = new URI("." + cssUri);
209                outputPath = _computeMinimizedUri(FilenameUtils.getName(cssUri));
210            }
211            else
212            {
213                // Minimize the css file as best as possible, due to an unknown location. No source map will be generated
214                options.setSourceMapFile(null);
215                inputPath = new URI("unknown-location");
216                outputPath = new URI("unknown-location");
217            }
218            return _jsassCompiler.compileString(content, inputPath, outputPath, options);
219        }
220        catch (CompilationException | URISyntaxException e)
221        {
222            throw new ProcessingException("Unable to Minimize the css file: " + cssUri, e);
223        }
224    }
225    
226    
227    private URI _computeMinimizedUri(String sourceMapUri) throws URISyntaxException
228    {
229        return new URI(StringUtils.removeEnd(sourceMapUri, ".css") + ".min.css");
230    }
231
232    private String _resolveImportUrl(String content, String nestedParentFilesName) throws URISyntaxException
233    {
234        StringBuffer sb = new StringBuffer();
235        
236        Matcher importMatcher = CSSFileHelper.IMPORT_PATTERN.matcher(content);
237
238        while (importMatcher.find()) 
239        {
240            String cssUrl = importMatcher.group(1);
241            String media = importMatcher.group(2);
242            
243            if (cssUrl != null && !cssUrl.startsWith("http://") && !cssUrl.startsWith("https://") && !cssUrl.startsWith("//"))
244            {
245                URI uri = new URI(cssUrl);
246                String uriToCompile = uri.isAbsolute() ? cssUrl : StringUtils.removeStart(cssUrl,  _proxiedContextPathProvider.getContextPath());
247                String importedContent = getMinimizedContent(uriToCompile, nestedParentFilesName);
248                importedContent += " /*! File end : " + nestedParentFilesName + uriToCompile + " */";
249                if (StringUtils.isNotEmpty(media))
250                {
251                    importedContent = applyMediaToContent(importedContent, media);
252                }
253                importMatcher.appendReplacement(sb, Matcher.quoteReplacement(importedContent));
254            }
255        }
256        
257        importMatcher.appendTail(sb);
258        
259        return sb.toString();
260    }
261    
262    @Override
263    protected void addSourceMap(SourceMapGeneratorV3 sourceMapGenerator, int lineCount, String fileContent, String fileUri, String sourceMapUri)
264    {
265        try
266        {
267            String sourceMapContent = null;
268            
269            // 1. Get source map from cache
270            String sourceMapKey = fileUri.indexOf('/') > -1 ? new URI(StringUtils.substringBeforeLast(fileUri, "/") + "/" + sourceMapUri).normalize().toString() : sourceMapUri;
271            sourceMapKey = StringUtils.removeStart(sourceMapKey, _proxiedContextPathProvider.getContextPath());
272            
273            Source mapSource = _sourceMapCache.get(sourceMapKey);
274            if (mapSource != null)
275            {
276                try (InputStream is = mapSource.getInputStream())
277                {
278                    sourceMapContent = IOUtils.toString(is, StandardCharsets.UTF_8);
279                }
280            }
281            else
282            {
283                // 2. If not found, get source map content from pipeline .css.map
284                String uriToResolve = new URI(sourceMapKey).isAbsolute() ? sourceMapKey : "cocoon:/" + sourceMapKey;
285                
286                try
287                {
288                    mapSource = _resolver.resolveURI(uriToResolve);
289                    if (mapSource.exists())
290                    {
291                        try (InputStream is = mapSource.getInputStream())
292                        {
293                            sourceMapContent = IOUtils.toString(is, "UTF-8");
294                        }
295                    }
296                }
297                catch (IOException e)
298                {
299                    // Nothing
300                }
301            }
302            
303            if (sourceMapContent != null)
304            {
305                // 3. aggregate content
306                sourceMapContent = JSASSFixHelper.fixJsassSourceMap(sourceMapContent, _proxiedContextPathProvider.getContextPath());
307        
308                // Add 1 to the line count to jump after the comment "/** File : */"
309                sourceMapGenerator.mergeMapSection(lineCount + 1, 0, sourceMapContent);
310            }
311            else
312            {
313                getLogger().warn("Unable to retrieve source map when aggregating hash file, for sourceMappingURL '" + sourceMapUri + "' of file '" + fileUri + "'");
314            }
315        }
316        catch (SourceMapParseException | IOException | URISyntaxException | IllegalStateException e)
317        {
318            // IllegalStateException occurs in "sourceMapGenerator.mergeMapSection" when a previous invalid source map offsets the line count too much
319            getLogger().error("Unable to merge source map of '" + fileUri + "' to the CSS minimized source map", e);
320        }
321    }
322
323    @Override
324    protected boolean isSourceMappingURLLine(String line)
325    {
326        return line != null && line.startsWith("/*# sourceMappingURL=") && line.endsWith(" */");
327    }
328
329    @Override
330    protected String getSourceMappingURL(String line)
331    {
332        return line.substring("/*# sourceMappingURL=".length(), line.length() - " */".length()).trim();
333    }
334
335    @Override
336    protected String removeSourceMappingURLLine(String content)
337    {
338        return content.substring(0, content.lastIndexOf("/*# sourceMappingURL="));
339    }
340
341    @Override
342    protected String formatSourceMappingURL(String sourceMapName)
343    {
344        return "/*# sourceMappingURL=" + sourceMapName + " */\n";
345    }
346
347
348}