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.js;
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.Arrays;
024import java.util.Collections;
025import java.util.regex.Matcher;
026import java.util.regex.Pattern;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.cocoon.ProcessingException;
030import org.apache.commons.io.IOUtils;
031import org.apache.commons.lang.StringEscapeUtils;
032import org.apache.commons.lang3.StringUtils;
033import org.apache.excalibur.source.Source;
034
035import org.ametys.core.minimize.AbstractMinimizeManager;
036
037import com.google.debugging.sourcemap.SourceMapGeneratorV3;
038import com.google.debugging.sourcemap.SourceMapParseException;
039import com.google.javascript.jscomp.CompilationLevel;
040import com.google.javascript.jscomp.Compiler;
041import com.google.javascript.jscomp.CompilerOptions;
042import com.google.javascript.jscomp.CompilerOptions.TracerMode;
043import com.google.javascript.jscomp.JSError;
044import com.google.javascript.jscomp.Result;
045import com.google.javascript.jscomp.SourceFile;
046import com.google.javascript.jscomp.WarningLevel;
047
048/**
049 * Manager for JS minimization and source map generation
050 */
051public class MinimizeJSManager extends AbstractMinimizeManager implements Component
052{
053    /** The avalon ROLE */
054    public static final String ROLE = MinimizeJSManager.class.getName();
055
056    // regex that matches the "file" property of the json sourcemap
057    private static final Pattern __SOURCEMAP_FILE_NAME = Pattern.compile(".*\"file\"\\s*:" // Matches the literal '"file" :' with any whitespace
058                                                                       + "\\s*"
059                                                                       + "\"([^\"]+)\".*", // Captures a string in between quotes 
060                                                                       Pattern.MULTILINE | Pattern.DOTALL);
061    
062    private static final Pattern __SOURCEMAP_LINE = Pattern.compile("^(.*)(\n//# sourceMappingURL=.*)\r?\n?$", Pattern.DOTALL);
063    
064    /**
065     * Minimize a JS string
066     * @param code The JS code
067     * @param location The JS location
068     * @return The minimized JS
069     * @throws ProcessingException If an error occurred during minimization
070     * @throws IOException If an error occurred while retrieving the source map
071     */
072    public String minimizeJS(String code, String location) throws ProcessingException, IOException
073    {
074        return minimizeJS(code, location, null, null);
075    }
076    
077    /**
078     * Minimize a JS string
079     * @param code The JS code
080     * @param location The JS location
081     * @param sourceMapKey The key to store the source map in cache. Can be null to prevent source map generation
082     * @param lastModified The last modified date of the code, used to assert validity of the source map cache. Can be null
083     * @return The minimized JS
084     * @throws ProcessingException If an error occurred during minimization
085     * @throws IOException If an error occurred while retrieving the source map
086     */
087    public String minimizeJS(String code, String location, String sourceMapKey, Long lastModified) throws IOException, ProcessingException
088    {
089        String fileName = StringUtils.substringAfterLast(location, "/");
090        boolean withSourceMap = sourceMapKey != null && lastModified != null;
091        Compiler compiler = _minimizeJS(code, fileName, withSourceMap);
092        
093        String sourceResult = compiler.toSource();
094        if (withSourceMap)
095        {
096            StringBuilder sb = new StringBuilder();
097            
098            compiler.getSourceMap().appendTo(sb, fileName);
099            _sourceMapCache.put(sourceMapKey, sb.toString(), lastModified);
100            
101            sourceResult += "\n" + formatSourceMappingURL(fileName + ".map");
102        }
103        
104        return sourceResult;
105    }
106    
107    /**
108     * Minimize a JS string and return the source map
109     * @param code The JS code
110     * @param location The JS location
111     * @param sourceMapKey The key to store the source map in cache
112     * @param lastModified The last modified date of the code, used to assert validity of the source map cache
113     * @return The minimized JS
114     * @throws ProcessingException If an error occurred during minimization
115     * @throws IOException If an error occurred while retrieving the source map
116     */
117    public String generateJSSourceMap(String code, String location, String sourceMapKey, Long lastModified) throws ProcessingException, IOException
118    {
119        String fileName = location.substring(location.lastIndexOf("/") + 1);
120        Compiler compiler = _minimizeJS(code, fileName, true);
121
122        StringBuilder sb = new StringBuilder();
123        compiler.toSource(); // required to generate the JS, and the source Map
124        compiler.getSourceMap().appendTo(sb, fileName);
125        String sourceMap = sb.toString();
126        _sourceMapCache.put(sourceMapKey, sourceMap, lastModified);
127        
128        return sourceMap;
129    }
130    
131    private Compiler _minimizeJS(String code, String fileName, boolean withSourceMap) throws ProcessingException
132    {
133        Compiler compiler = new Compiler();
134        CompilerOptions options = new CompilerOptions();
135        
136        options.setTracerMode(TracerMode.OFF);
137        WarningLevel.QUIET.setOptionsForWarningLevel(options);
138        if (withSourceMap)
139        {
140            options.setSourceMapOutputPath("unused");
141        }
142        options.setEmitUseStrict(false);
143        options.setOutputCharset(StandardCharsets.UTF_8);
144        
145        CompilationLevel level = CompilationLevel.WHITESPACE_ONLY;
146        level.setOptionsForCompilationLevel(options);
147        
148        SourceFile file = SourceFile.fromCode(fileName, code);
149        Result result = compiler.compile(Collections.emptyList(), Arrays.asList(file), options);
150        if (result.errors.size() > 0)
151        {
152            StringBuilder errorsString = new StringBuilder();
153            errorsString.append("Unable to minimize " + fileName);
154            for (JSError error : result.errors)
155            {
156                errorsString.append("\n" + error.toString());
157            }
158            throw new ProcessingException(errorsString.toString());
159        }
160        
161        return compiler;
162    }
163
164    @Override
165    protected void addSourceMap(SourceMapGeneratorV3 sourceMapGenerator, int lineCount, String fileContent, String fileUri, String sourceMapUri)
166    {
167        try
168        {
169            String sourceMapCacheKey = fileUri.indexOf('/') > -1 ? fileUri.substring(0, fileUri.lastIndexOf("/") + 1) + sourceMapUri : sourceMapUri;
170            
171            Source mapSource = _sourceMapCache.get(sourceMapCacheKey);
172            
173            if (mapSource == null)
174            {
175                // If not found, get source map content from pipeline .css.map, e.g. if the js file was already minified
176                String uriToResolve = new URI(sourceMapCacheKey).isAbsolute() ? sourceMapCacheKey : "cocoon:/" + sourceMapCacheKey;
177                
178                try
179                {
180                    mapSource = _resolver.resolveURI(uriToResolve);
181                }
182                catch (IOException e)
183                {
184                    // Nothing
185                }
186            }
187            
188            if (mapSource != null && mapSource.exists())
189            {
190                String sourceMapContent;
191                try (InputStream is = mapSource.getInputStream())
192                {
193                    sourceMapContent = IOUtils.toString(is, StandardCharsets.UTF_8);
194                }
195                
196                Matcher fileMatcher = __SOURCEMAP_FILE_NAME.matcher(sourceMapContent);
197                if (fileMatcher.matches())
198                {
199                    sourceMapContent = sourceMapContent.replaceFirst("\"file\":\"" + fileMatcher.group(1) + "\"", "\"file\":\"" + _proxiedContextPathProvider.getContextPath() + fileUri + "\"");
200                }
201                
202                sourceMapContent = super.convertSourceMapURIs(sourceMapContent, fileUri);
203                
204                // Add 1 to the line count to jump after the comment "/** File : */ 
205                // and 1 to the line count for document_currentScript"
206                sourceMapGenerator.mergeMapSection(lineCount + 2, 0, sourceMapContent);
207            }
208        }
209        catch (SourceMapParseException | IOException | URISyntaxException | IllegalStateException e)
210        {
211            // IllegalStateException occurs in "sourceMapGenerator.mergeMapSection" when a previous invalid source map offsets the line count too much
212            getLogger().error("Unable to retrieve source map when aggregating hash file, for sourceMappingURL '" + sourceMapUri + "' of file '" + fileUri + "'", e);
213        }
214    }
215
216
217    @Override
218    protected String getMinimizedContent(String uri, String nestedParentFilesName)
219    {
220        StringBuffer sb = new StringBuffer();
221        
222        sb.append("/** File : " + uri + " */\n");
223        sb.append("document_currentScript = (function() { a = document.createElement('script'); a.src='" + _proxiedContextPathProvider.getContextPath() + uri + "'; return a; })(); try {\n");
224        
225        Source jssource = null;
226        String suffix = null;
227        try
228        {
229            String minimizeFileUri = StringUtils.endsWith(uri, ".min.js") ? uri : StringUtils.removeEnd(uri, ".js") + ".min.js";
230            URI minimizeUri = new URI(minimizeFileUri);
231            
232            String uriToResolve = minimizeUri.isAbsolute() ? minimizeFileUri : "cocoon:/" + org.apache.cocoon.util.NetUtils.normalize(minimizeFileUri);
233            jssource = _resolver.resolveURI(uriToResolve);
234            
235            String s;
236            try (InputStream is = jssource.getInputStream())
237            {
238                s = IOUtils.toString(is, StandardCharsets.UTF_8);
239            }
240            
241            s = s.replaceAll("document\\.currentScript", "document_currentScript");
242
243            // We extract the sourcemap comment, to put it after the "catch"
244            Matcher matcher = __SOURCEMAP_LINE.matcher(s);
245            if (matcher.matches())
246            {
247                s = matcher.group(1);
248                suffix = matcher.group(2) + "\n"; // \n is optional in the regexp. we force it to be present this way. 
249            }
250            
251            sb.append(s);
252        }
253        catch (Exception e)
254        {
255            getLogger().error("Cannot minimize JS for file '" + uri + "'.", e);
256            Throwable cause = e;
257            while (cause.getCause() != null)
258            {
259                cause = cause.getCause();
260            }
261            sb.append("throw new Error('" + StringEscapeUtils.escapeJavaScript(cause.getMessage()) + "');");
262        }
263        finally
264        {
265            _resolver.release(jssource);
266            
267            sb.append("} catch (e) { console.error(\"" + uri + "\\n\", e) }");
268            if (StringUtils.isNotBlank(suffix))
269            {
270                sb.append(suffix);
271            }
272        }
273
274        sb.append("\n");
275        return sb.toString();
276    }
277
278    @Override
279    protected boolean isSourceMappingURLLine(String line)
280    {
281        return line != null && line.startsWith("//# sourceMappingURL=");
282    }
283
284    @Override
285    protected String getSourceMappingURL(String line)
286    {
287        return line.substring("//# sourceMappingURL=".length()).trim();
288    }
289
290    @Override
291    protected String removeSourceMappingURLLine(String content)
292    {
293        return content.substring(0, content.lastIndexOf("//# sourceMappingURL="));
294    }
295
296    @Override
297    protected String formatSourceMappingURL(String sourceMapName)
298    {
299        return "//# sourceMappingURL=" + sourceMapName + "\n";
300    }
301
302}