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