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}