001/* 002 * Copyright 2016 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 */ 016 017package org.ametys.plugins.core.ui.minimize; 018 019import java.io.IOException; 020import java.util.ArrayList; 021import java.util.Arrays; 022import java.util.Collections; 023import java.util.Comparator; 024import java.util.HashMap; 025import java.util.HashSet; 026import java.util.LinkedHashMap; 027import java.util.List; 028import java.util.Map; 029import java.util.Set; 030import java.util.regex.Matcher; 031import java.util.regex.Pattern; 032import java.util.stream.Collectors; 033 034import org.apache.avalon.framework.configuration.Configurable; 035import org.apache.avalon.framework.configuration.Configuration; 036import org.apache.avalon.framework.configuration.ConfigurationException; 037import org.apache.avalon.framework.context.Context; 038import org.apache.avalon.framework.context.ContextException; 039import org.apache.avalon.framework.context.Contextualizable; 040import org.apache.avalon.framework.parameters.Parameters; 041import org.apache.avalon.framework.service.ServiceException; 042import org.apache.avalon.framework.service.ServiceManager; 043import org.apache.cocoon.ProcessingException; 044import org.apache.cocoon.components.ContextHelper; 045import org.apache.cocoon.environment.ObjectModelHelper; 046import org.apache.cocoon.environment.Request; 047import org.apache.cocoon.transformation.ServiceableTransformer; 048import org.apache.cocoon.xml.AttributesImpl; 049import org.apache.commons.io.FilenameUtils; 050import org.apache.commons.lang3.StringUtils; 051import org.xml.sax.Attributes; 052import org.xml.sax.SAXException; 053 054import org.ametys.core.DevMode; 055import org.ametys.core.DevMode.DEVMODE; 056import org.ametys.runtime.workspace.WorkspaceMatcher; 057 058/** 059 * This transformer will minimize every scripts together 060 */ 061public class MinimizeTransformer extends ServiceableTransformer implements Contextualizable, Configurable 062{ 063 private static final String __DEFAULT_URI_PATTERN = "^/plugins/[^/]+/resources/"; 064 private static final Set<String> __ALL_MEDIA = Set.of("print", "screen", "speech"); // Keep alphabetical order 065 066 /** Is developer mode? */ 067 protected Boolean _isSuperDevMode; 068 /** The avalon context */ 069 protected Context _context; 070 /** The list of patterns available for minimization */ 071 protected List<Pattern> _patterns; 072 /** The current local */ 073 protected String _locale; 074 /** Should css medias be taken in account? */ 075 protected Boolean _inlineCssMedias; 076 /** The current context */ 077 protected String _currentContextPath; 078 079 /* Current queue being minified */ 080 private Map<String, Map<String, String>> _filesQueue; 081 private String _queueTag; 082 private Set<String> _queueMedia; 083 private boolean _isCurrentTagQueued; 084 085 private HashCache _hashCache; 086 087 @Override 088 public void service(ServiceManager smanager) throws ServiceException 089 { 090 super.service(smanager); 091 092 _hashCache = (HashCache) smanager.lookup(HashCache.ROLE); 093 } 094 095 @Override 096 public void contextualize(Context context) throws ContextException 097 { 098 _context = context; 099 } 100 101 @Override 102 public void configure(Configuration configuration) throws ConfigurationException 103 { 104 Configuration paternsConfig = configuration.getChild("patterns"); 105 106 _patterns = new ArrayList<>(); 107 for (Configuration paternConfig : paternsConfig.getChildren("pattern")) 108 { 109 Pattern pattern = Pattern.compile(paternConfig.getValue()); 110 _patterns.add(pattern); 111 } 112 113 if (_patterns.isEmpty()) 114 { 115 _patterns.add(Pattern.compile(__DEFAULT_URI_PATTERN)); 116 } 117 118 _inlineCssMedias = Boolean.valueOf(configuration.getChild("inline-css-medias").getValue("true")); 119 } 120 121 /** 122 * Get the url of the minimizer 123 * @return The minimizer url 124 */ 125 protected String _getMinimizeUrl() 126 { 127 Request request = ObjectModelHelper.getRequest(objectModel); 128 return request.getContextPath() + StringUtils.defaultString((String) request.getAttribute(WorkspaceMatcher.WORKSPACE_URI)) + "/plugins/core-ui/resources-minimized/"; 129 } 130 131 @Override 132 public void setup(org.apache.cocoon.environment.SourceResolver res, Map obj, String src, Parameters par) throws ProcessingException, SAXException, IOException 133 { 134 super.setup(res, obj, src, par); 135 136 Request request = ContextHelper.getRequest(_context); 137 _locale = request.getLocale().getLanguage(); 138 _isSuperDevMode = DEVMODE.SUPER_DEVELOPPMENT.equals(DevMode.getDeveloperMode(request)); 139 _currentContextPath = request.getContextPath(); 140 } 141 142 @Override 143 public void startDocument() throws SAXException 144 { 145 _filesQueue = new LinkedHashMap<>(); 146 _queueTag = ""; 147 _isCurrentTagQueued = false; 148 _queueMedia = new HashSet<>(); 149 150 super.startDocument(); 151 } 152 153 @Override 154 public void startElement(String uri, String loc, String raw, Attributes a) throws SAXException 155 { 156 if (!_isSuperDevMode) 157 { 158 if (_isMinimizable(StringUtils.lowerCase(loc), a)) 159 { 160 _isCurrentTagQueued = true; 161 _addToQueue(StringUtils.lowerCase(loc), a); 162 } 163 else 164 { 165 _isCurrentTagQueued = false; 166 167 if (_filesQueue.size() > 0) 168 { 169 _processQueue(); 170 } 171 172 super.startElement(uri, loc, raw, a); 173 } 174 } 175 else 176 { 177 super.startElement(uri, loc, raw, a); 178 } 179 } 180 181 @Override 182 public void endElement(String uri, String loc, String raw) throws SAXException 183 { 184 if (!_isSuperDevMode) 185 { 186 if (_isCurrentTagQueued) 187 { 188 _isCurrentTagQueued = false; 189 } 190 else 191 { 192 if (_filesQueue.size() > 0) 193 { 194 _processQueue(); 195 } 196 197 super.endElement(uri, loc, raw); 198 } 199 } 200 else 201 { 202 super.endElement(uri, loc, raw); 203 } 204 } 205 206 /** 207 * Check if the current tag can be minimized by this transformer 208 * @param tagName The tag name 209 * @param attrs The attribute 210 * @return True if minimizable 211 */ 212 private boolean _isMinimizable(String tagName, Attributes attrs) 213 { 214 if ("true".equals(attrs.getValue("data-donotminimize"))) 215 { 216 return false; 217 } 218 219 String uri = null; 220 221 if ("script".equals(tagName)) 222 { 223 String type = attrs.getValue("type"); 224 if (StringUtils.isNotEmpty(type) && !"text/javascript".equals(type) && !"application/javascript".equals(type)) 225 { 226 // unsupported script type 227 return false; 228 } 229 230 uri = attrs.getValue("src"); 231 } 232 233 if ("link".equals(tagName)) 234 { 235 String type = attrs.getValue("type"); 236 if (StringUtils.isNotEmpty(type) && !"text/css".equals(type)) 237 { 238 // unsupported script type 239 return false; 240 } 241 242 String rel = attrs.getValue("rel"); 243 if (!"stylesheet".equals(rel)) 244 { 245 // unsupported relation type 246 return false; 247 } 248 249 uri = attrs.getValue("href"); 250 } 251 252 if (StringUtils.isNotEmpty(uri)) 253 { 254 uri = FilenameUtils.normalize(uri, true); 255 uri = _removeContextPath(uri); 256 257 for (Pattern pattern : _patterns) 258 { 259 Matcher matcher = pattern.matcher(uri); 260 if (matcher.find()) 261 { 262 return true; 263 } 264 } 265 } 266 267 return false; 268 } 269 270 /** 271 * Remove the context path from the uri to be able to analyse correctly the patterns 272 * @param uri The incomming uri 273 * @return The uri with no context path 274 */ 275 protected String _removeContextPath(String uri) 276 { 277 return StringUtils.prependIfMissing(StringUtils.removeStart(uri, _currentContextPath + "/"), "/"); 278 } 279 280 /** 281 * Add a new tag to the current queue. If the new tag is not compatible with the current queue, it is processed first and emptied before adding the new type of tag. 282 * @param tagName The tag name 283 * @param attrs The tag attribute 284 * @throws SAXException If an error occurred 285 */ 286 private void _addToQueue(String tagName, Attributes attrs) throws SAXException 287 { 288 Map<String, String> fileOptions = new HashMap<>(); 289 290 if (_filesQueue.size() > 0) 291 { 292 // Check if can be added to current queue. If not, process queue before adding. 293 boolean sameAsCurrentQueue = tagName.equals(_queueTag); 294 295 if (sameAsCurrentQueue && "link".equals(tagName)) 296 { 297 String media = attrs.getValue("media"); 298 Set<String> medias = _toMediaList(media); 299 300 if (_inlineCssMedias || _areAlwaysInlinableMedias(medias)) 301 { 302 // allow different medias in queue, but store the value as file option 303 fileOptions.put("media", StringUtils.join(medias, ",")); 304 } 305 else 306 { 307 // media must be the same as current queue 308 sameAsCurrentQueue = medias.equals(_queueMedia); 309 } 310 } 311 312 if (!sameAsCurrentQueue) 313 { 314 _processQueue(); 315 } 316 } 317 318 String uri = FilenameUtils.normalize("script".equals(tagName) ? attrs.getValue("src") : attrs.getValue("href"), true); 319 320 if (StringUtils.isNotEmpty(uri)) 321 { 322 uri = FilenameUtils.normalize(uri, true); 323 uri = _removeContextPath(uri); 324 325 fileOptions.put("tag", tagName); 326 _filesQueue.put(uri, fileOptions); 327 if (_filesQueue.size() == 1) 328 { 329 // first item in queue, set the queue attributes for further additions compatibility checks. 330 _queueTag = tagName; 331 332 if (!_inlineCssMedias && "link".equals(tagName)) 333 { 334 String media = attrs.getValue("media"); 335 _queueMedia = _toMediaList(media); 336 } 337 } 338 } 339 } 340 341 private boolean _areAlwaysInlinableMedias(Set<String> medias) 342 { 343 // Since IE9, print can be inlined in other media css files 344 return Collections.singleton("print").equals(medias) // The new file is print only 345 && (_queueMedia.contains("print") || _queueMedia.isEmpty()); // AND The current file has print included (empty media == all) 346 } 347 348 /** 349 * Process the current files queue by saxing it as one file, which name is the hash of the sum of files. 350 * This hash is cached, to allow the retrieval of the real files from the hash value. 351 * The files parameters, such as the media value of css files, are also stored in the cache. 352 * @throws SAXException If an error occurred 353 */ 354 private void _processQueue() throws SAXException 355 { 356 String hash = _hashCache.createHash(_filesQueue, getHashSalt()); 357 358 AttributesImpl attrs = new AttributesImpl(); 359 360 String minimizeUrl = _getMinimizeUrl(); 361 362 if ("script".equals(_queueTag)) 363 { 364 attrs.addCDATAAttribute("type", "text/javascript"); 365 attrs.addCDATAAttribute("src", minimizeUrl + hash + ".js"); 366 } 367 368 if ("link".equals(_queueTag)) 369 { 370 attrs.addCDATAAttribute("type", "text/css"); 371 attrs.addCDATAAttribute("rel", "stylesheet"); 372 attrs.addCDATAAttribute("href", minimizeUrl + hash + ".css"); 373 374 if (!_inlineCssMedias && !_queueMedia.isEmpty()) 375 { 376 String medias = StringUtils.join(_queueMedia, ","); 377 attrs.addCDATAAttribute("media", medias); 378 } 379 } 380 381 super.startElement("", _queueTag, _queueTag, attrs); 382 super.endElement("", _queueTag, _queueTag); 383 384 _queueTag = null; 385 _filesQueue.clear(); 386 } 387 388 /** 389 * Return the salt used by the hash cache 390 * @return The salt string 391 */ 392 protected String getHashSalt() 393 { 394 return _locale; 395 } 396 397 private Set<String> _toMediaList(String media) 398 { 399 Set<String> medias = Arrays.asList(StringUtils.split(StringUtils.defaultString(media, "").toLowerCase(), ",")).stream() 400 .map(String::trim) 401 .sorted(Comparator.naturalOrder()) 402 .collect(Collectors.toSet()); 403 404 if (medias.contains("all") || medias.isEmpty() || __ALL_MEDIA.equals(medias)) 405 { 406 return Collections.emptySet(); 407 } 408 409 return medias; 410 } 411}