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}