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