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.resources; 018 019import java.io.IOException; 020import java.io.InputStream; 021import java.net.MalformedURLException; 022import java.net.URI; 023import java.net.URISyntaxException; 024import java.nio.charset.StandardCharsets; 025import java.util.ArrayList; 026import java.util.Collection; 027import java.util.HashMap; 028import java.util.LinkedList; 029import java.util.List; 030import java.util.Map; 031import java.util.regex.Matcher; 032import java.util.regex.Pattern; 033import java.util.stream.Collectors; 034 035import org.apache.avalon.framework.parameters.Parameters; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.cocoon.ProcessingException; 039import org.apache.commons.io.FilenameUtils; 040import org.apache.commons.io.IOUtils; 041import org.apache.commons.lang3.StringUtils; 042import org.apache.excalibur.source.Source; 043import org.apache.excalibur.source.SourceNotFoundException; 044 045import io.bit3.jsass.CompilationException; 046import io.bit3.jsass.Compiler; 047import io.bit3.jsass.Options; 048import io.bit3.jsass.Output; 049import io.bit3.jsass.importer.Import; 050import io.bit3.jsass.importer.Importer; 051 052/** 053 * Reader for SASS files, compile them on the fly into CSS files. 054 */ 055public class SassResourceHandler extends AbstractCompiledResourceHandler 056{ 057 private static final Pattern __IMPORT_PATTERN = Pattern.compile("^\\s*@import\\s+(?:(?:url)?\\(?\\s*[\"']?)([^)\"']+)[\"']?\\)?\\s*;?\\s*$", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); 058 private static final String[] __SASS_EXTENSION = new String[] {".scss", ".sass"}; 059 060 private Compiler _jsassCompiler; 061 062 private List<SassFunctionsProvider> _sassFunctionsProviders; 063 064 @Override 065 public void service(ServiceManager serviceManager) throws ServiceException 066 { 067 super.service(serviceManager); 068 SassFunctionsProviderExtensionPoint sassFunctionsProviderEP = (SassFunctionsProviderExtensionPoint) serviceManager.lookup(SassFunctionsProviderExtensionPoint.ROLE); 069 _sassFunctionsProviders = sassFunctionsProviderEP.getExtensionsIds().stream().map(id -> sassFunctionsProviderEP.getExtension(id)).collect(Collectors.toList()); 070 } 071 072 @Override 073 public int getPriority() 074 { 075 return MIN_PRIORITY + 1000; 076 } 077 078 @Override 079 public boolean isSupported(String source) 080 { 081 if (!super.isSupported(source)) 082 { 083 return false; 084 } 085 086 String lcSource = StringUtils.lowerCase(source); 087 if (lcSource.endsWith(".css")) 088 { 089 String sourceWithExt = StringUtils.substringBeforeLast(source, ".css"); 090 for (String ext : __SASS_EXTENSION) 091 { 092 Source src = null; 093 try 094 { 095 src = _resolver.resolveURI(sourceWithExt + ext); 096 if (src.exists()) 097 { 098 return true; 099 } 100 } 101 catch (IOException e) 102 { 103 // Nothing 104 } 105 finally 106 { 107 _resolver.release(src); 108 } 109 } 110 111 // No .scss or .sass file found 112 return false; 113 } 114 else 115 { 116 return true; 117 } 118 } 119 120 @Override 121 protected Source getCompiledSource(String location) throws MalformedURLException, IOException 122 { 123 if (location.toLowerCase().endsWith(".css")) 124 { 125 String sourceWithExt = StringUtils.substringBeforeLast(location, ".css"); 126 for (String ext : __SASS_EXTENSION) 127 { 128 Source src = _resolver.resolveURI(sourceWithExt + ext); 129 if (src.exists()) 130 { 131 return src; 132 } 133 } 134 } 135 136 return _resolver.resolveURI(location); 137 } 138 139 @Override 140 public Source setup(String location, Parameters par) throws ProcessingException, IOException 141 { 142 Source source = super.setup(location, par); 143 144 _jsassCompiler = new Compiler(); 145 146 return source; 147 } 148 149 @Override 150 public String compileResource(Source resource) throws IOException, ProcessingException 151 { 152 Output compiledString = null; 153 Options options = new Options(); 154 AmetysSassImporter sassImporter = new AmetysSassImporter(); 155 options.getImporters().add(sassImporter); 156 options.getFunctionProviders().addAll(_sassFunctionsProviders); 157 158 try (InputStream is = resource.getInputStream()) 159 { 160 URI uri = new URI(resource.getURI()); 161 sassImporter.registerValidURI(uri.toString()); 162 String sassContent = IOUtils.toString(is, StandardCharsets.UTF_8); 163 compiledString = _jsassCompiler.compileString(sassContent, uri, uri, options); 164 } 165 catch (CompilationException | URISyntaxException e) 166 { 167 throw new ProcessingException("Unable to compile the SASS file: " + resource.getURI(), e); 168 } 169 170 return compiledString.getCss(); 171 } 172 173 174 @Override 175 protected List<String> getDependenciesList(Source inputSource) 176 { 177 List<String> result = new ArrayList<>(); 178 179 try (InputStream is = inputSource.getInputStream()) 180 { 181 String previous = inputSource.getURI(); 182 String content = IOUtils.toString(is, StandardCharsets.UTF_8); 183 184 Matcher matcher = __IMPORT_PATTERN.matcher(content); 185 186 while (matcher.find()) 187 { 188 String cssUrl = matcher.group(1); 189 190 if (!StringUtils.contains(cssUrl, "http://") && !StringUtils.contains(cssUrl, "https://")) 191 { 192 URI currentUri = new URI(cssUrl); 193 if (!currentUri.isAbsolute()) 194 { 195 currentUri = new URI(FilenameUtils.getFullPath(previous.toString()) + cssUrl); 196 } 197 198 Source importSource = _getImportSource(currentUri.toString()); 199 200 result.add(importSource.getURI().toString()); 201 } 202 } 203 } 204 catch (IOException | URISyntaxException e) 205 { 206 getLogger().warn("Invalid " + inputSource.getURI(), e); 207 } 208 209 return result; 210 } 211 212 @Override 213 public String getMimeType(Source source, Parameters par) 214 { 215 return "text/css"; 216 } 217 218 /** 219 * Get the Sass source from the current Uri 220 * @param currentUri The URI 221 * @return The Sass source 222 * @throws URISyntaxException If the Uri does not match a source 223 * @throws IOException If an error occurred 224 */ 225 protected Source _getImportSource(String currentUri) throws URISyntaxException, IOException 226 { 227 List<String> uriMatching = new ArrayList<>(); 228 uriMatching.add(currentUri); 229 230 // extension is optional 231 uriMatching.add(currentUri + ".scss"); 232 uriMatching.add(currentUri + ".sass"); 233 uriMatching.add(currentUri + ".css"); 234 235 // add an underscore prefix to the file name for sass partial imports 236 String name = FilenameUtils.getName(currentUri); 237 String partialUri = currentUri.substring(0, currentUri.length() - name.length()) + "_" + name; 238 uriMatching.add(partialUri); 239 uriMatching.add(partialUri + ".scss"); 240 uriMatching.add(partialUri + ".sass"); 241 uriMatching.add(partialUri + ".css"); 242 243 for (String uri : uriMatching) 244 { 245 try 246 { 247 Source importSource = _resolver.resolveURI(uri); 248 if (importSource.exists()) 249 { 250 return importSource; 251 } 252 } 253 catch (SourceNotFoundException e) 254 { 255 // source does not exists. Do nothing 256 } 257 } 258 259 throw new URISyntaxException(currentUri, "Unable to resolve SASS import, no matching source found"); 260 } 261 262 /** 263 * Sass Importer which can resolve Ametys resources 264 */ 265 private class AmetysSassImporter implements Importer 266 { 267 private Map<String, String> _validURIsRegistered; 268 269 /** 270 * Default constructor for the Ametys SassImporter. Provides information for resolving imported resources. 271 */ 272 public AmetysSassImporter() 273 { 274 _validURIsRegistered = new HashMap<>(); 275 } 276 277 /** 278 * Because Libsass does not fully respect URI schemes and can sometimes corrupt URIs, this method can be used to provide a URI known as valid. 279 * When resolving the URI received from Libsass, it will first be checked against valid URIs, to prevent common corruption. 280 * Example of known URI corruption : scheme of URI "plugin:*://" will be transformed into "plugin:*:/" 281 * @param validUri A valid URI 282 */ 283 public void registerValidURI(String validUri) 284 { 285 if (StringUtils.contains(validUri, "://")) 286 { 287 String schema = validUri.substring(0, validUri.indexOf("://")); 288 String corruptedUri = schema + ":/" + StringUtils.removeStart(validUri, schema + "://"); 289 _validURIsRegistered.put(corruptedUri, validUri); 290 } 291 } 292 293 public Collection<Import> apply(String url, Import previous) 294 { 295 if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//")) 296 { 297 // returning null keeps the original @import mention 298 return null; 299 } 300 301 List<Import> list = new LinkedList<>(); 302 URI currentUri = null; 303 304 try 305 { 306 URI importUrl = new URI(url); 307 if (importUrl.isAbsolute()) 308 { 309 currentUri = importUrl; 310 this.registerValidURI(currentUri.toString()); 311 } 312 else 313 { 314 if (_validURIsRegistered.containsKey(previous.getAbsoluteUri().toString())) 315 { 316 // URI replaced with the registered matching valid URI, because of potential URI corruption. 317 currentUri = new URI(FilenameUtils.getFullPath(_validURIsRegistered.get(previous.getAbsoluteUri().toString())) + url); 318 } 319 else 320 { 321 currentUri = new URI(FilenameUtils.getFullPath(previous.getAbsoluteUri().toString()) + url); 322 } 323 } 324 325 Source importSource = _getImportSource(currentUri.toString()); 326 327 if (importSource.getURI().endsWith(".scss") 328 || importSource.getURI().endsWith(".sass") 329 || importSource.getURI().endsWith(".css") && !currentUri.toString().endsWith(".css")) // An url ending at the origin by .css should stay an import at the end (while a .css added automatically should be resolved) 330 { 331 String importText = IOUtils.toString(importSource.getInputStream(), "UTF-8"); 332 list.add(new Import(currentUri, currentUri, importText)); 333 } 334 else 335 { 336 // returning null keeps the original @import mention 337 return null; 338 } 339 } 340 catch (URISyntaxException e) 341 { 342 throw new RuntimeException(e); 343 } 344 catch (IOException e) 345 { 346 throw new RuntimeException(e); 347 } 348 349 return list; 350 } 351 352 353 } 354}