/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package org.jooby.assets; import static java.util.Objects.requireNonNull; import static javaslang.Predicates.instanceOf; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; import org.jooby.Asset; import org.jooby.MediaType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.CaseFormat; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.hash.Hashing; import com.google.common.io.BaseEncoding; import com.google.common.io.ByteStreams; import com.google.common.io.Closeables; import com.google.common.io.Files; import com.typesafe.config.Config; import com.typesafe.config.ConfigFactory; import com.typesafe.config.ConfigValue; import javaslang.control.Try; /** * <h1>Asset compiler</h1> * <p> * Process static files by validate or modify them in some way. * </p> * * @author edgar * @see AssetProcessor * @see Assets * @since 0.11.0 */ public class AssetCompiler { /** The logging system. */ private final Logger log = LoggerFactory.getLogger(getClass()); private final Map<String, List<AssetProcessor>> pipeline; private final List<AssetAggregator> aggregators = new ArrayList<>(); private final Map<String, List<String>> fileset; private final Predicate<String> scripts; private final Predicate<String> styles; private final Config conf; private final Charset charset; private ClassLoader loader; public AssetCompiler(final Config conf) throws Exception { this(conf.getClass().getClassLoader(), conf); } public AssetCompiler(final ClassLoader loader, final Config conf) throws Exception { this.loader = AssetClassLoader.classLoader(loader); this.conf = requireNonNull(conf, "Assets conf is required."); String basedir = conf.hasPath("assets.basedir") ? spath(conf.getString("assets.basedir")) : ""; this.charset = Charset.forName(this.conf.getString("assets.charset")); if (this.conf.hasPath("assets.fileset")) { this.fileset = fileset(loader, basedir, this.conf, aggregators::add); } else { this.fileset = new HashMap<>(); } this.scripts = predicate(this.conf, ".js", ".coffee", ".ts"); this.styles = predicate(this.conf, ".css", ".scss", ".sass", ".less"); if (this.fileset.size() > 0) { this.pipeline = pipeline(loader, this.conf.getConfig("assets")); } else { this.pipeline = Collections.emptyMap(); } } /** * Get all the assets for the provided file set. Example: * * <pre> * assets { * fileset { * home: [home.css, home.js] * } * } * </pre> * * This method returns <code>home.css</code> and <code>home.js</code> for <code>home</code> file * set. If there is no fileset under that name then it returns an empty list. * * @param name Fileset name. * @return List of files or empty list. */ public List<String> assets(final String name) { return fileset.getOrDefault(name, Collections.emptyList()); } /** * @return Returns all the fileset. */ public Set<String> fileset() { return fileset.keySet(); } /** * Iterate over fileset and common path pattern for them. Example: * * <pre> * { * assets { * fileset { * lib: [js/lib/jquery.js], * home: [css/style.css, js/home.js] * } * } * } * </pre> * * This method returns a set with <code>/css/**</code> and <code>/js/**</code> pattern. * * @return Path pattern of the entire fileset. */ public Set<String> patterns() { return patterns(file -> !aggregators.stream() .filter(it -> it.fileset().contains(file)) .findFirst().isPresent()) .map(v -> "/" + v + "/**") .collect(Collectors.toCollection(LinkedHashSet::new)); } /** * Test if the provided path is part of the fileset. * * @param path Test to test. * @return True, if the path belong to the filset. */ public boolean contains(final String path) { Predicate<List<String>> filter = fs -> fs.stream() .filter(it -> path.endsWith(it)) .findFirst() .isPresent(); boolean generated = aggregators.stream() .map(AssetAggregator::fileset) .filter(filter) .findFirst() .isPresent(); if (!generated) { return fileset.values().stream() .filter(filter) .findFirst() .isPresent(); } return false; } /** * Get all the javascript (or derived) for the provided fileset. Example: * * <pre> * { * assets { * fileset { * mypage: [mypage.js, mypage.css] * } * } * } * </pre> * * <p> * This method returns <code>mypage.js</code> for <code>mypage</code> fileset. * </p> * * @param fileset Fileset name. * @return All the scripts for a fileset. */ public List<String> scripts(final String fileset) { return assets(fileset) .stream() .filter(scripts) .collect(Collectors.toList()); } /** * Get all the css files (or derived) for the provided fileset. Example: * * <pre> * { * assets { * fileset { * mypage: [mypage.js, mypage.css] * } * } * } * </pre> * * <p> * This method returns <code>mypage.js</code> for <code>mypage</code> fileset. * </p> * * @param fileset Fileset name. * @return All the scripts for a fileset. */ public List<String> styles(final String fileset) { return assets(fileset) .stream() .filter(styles) .collect(Collectors.toList()); } /** * List all the {@link AssetProcessor} for a distribution (a.k.a. environment). * * @param dist Distribution's name. * @return A readonly list of available {@link AssetProcessor}. */ public List<AssetProcessor> pipeline(final String dist) { List<AssetProcessor> chain = this.pipeline.get(dist); if (chain == null) { log.debug("no pipeline for: {}", dist); return Collections.emptyList(); } return Collections.unmodifiableList(chain); } /** * @return Readonly list of available {@link AssetAggregator}. */ public List<AssetAggregator> aggregators() { return Collections.unmodifiableList(aggregators); } /** * Build assets using the given distribution and write output to the provided directory. * * Build process is defined as follow: * * 1. First, it runs all the aggregators (if any) * 2. Then iterates each fileset and per each file in the fileset it apply the processor pipeline. * 3. Finally, it merge all the files into one file and compressed/optimized if need it. * * @param dist Distribution's name (usually dev or dist). * @param dir Output directory. * @return Map with fileset name as key and list of generated assets. * @throws Exception If something goes wrong. */ public Map<String, List<File>> build(final String dist, final File dir) throws Exception { Map<String, List<File>> output = new LinkedHashMap<>(); log.info("{} aggregators: {}", dist, aggregators); aggregators(aggregators, conf); List<AssetProcessor> pipeline = pipeline(dist); log.info("{} pipeline: {}", dist, pipeline); for (String fset : fileset()) { List<String> files = assets(fset); log.info("compiling {}:", fset); String css = compile(pipeline, files.stream().filter(styles).iterator(), MediaType.css, ""); Path cssSha1 = Paths.get(fset + "." + sha1(css) + ".css"); Path pcss = patterns(styles).findFirst() .map(p -> Paths.get(p).resolve(cssSha1)) .orElse(cssSha1); File fcss = dir.toPath().resolve(pcss).toFile(); fcss.getParentFile().mkdirs(); ImmutableList.Builder<File> outputbuilder = ImmutableList.builder(); if (css.length() > 0) { Files.write(css, fcss, charset); outputbuilder.add(fcss); } String js = compile(pipeline, files.stream().filter(scripts).iterator(), MediaType.js, ";"); Path jsSha1 = Paths.get(fset + "." + sha1(js) + ".js"); Path pjs = patterns(scripts).findFirst() .map(p -> Paths.get(p).resolve(jsSha1)) .orElse(jsSha1); File fjs = dir.toPath().resolve(pjs).toFile(); fjs.getParentFile().mkdirs(); if (js.length() > 0) { Files.write(js, fjs, charset); outputbuilder.add(fjs); } List<File> fsoutput = outputbuilder.build(); fsoutput.forEach( it -> log.info("{} {} ({})", it.getName(), humanReadableByteCount(it.length()), it)); output.put(fset, fsoutput); } return output; } private void aggregators(final List<AssetAggregator> aggregators, final Config conf) throws Exception { for (AssetAggregator it : aggregators) { log.info("applying {}", it); it.run(conf); } } /** * Apply the processor pipeline to the given asset. Like {@link #build(String, File)} but for a * single file or asset. * * @param asset Asset to build. * @return Processed asset. * @throws Exception If something goes wrong. */ public Asset build(final Asset asset) throws Exception { if (pipeline.size() == 0) { return asset; } String filename = asset.path(); final MediaType type; if (scripts.test(filename)) { type = MediaType.js; } else if (styles.test(filename)) { type = MediaType.css; } else { return asset; } List<AssetProcessor> pipeline = pipeline("dev"); String output = compile(pipeline, filename, type, toString(asset.stream(), charset)); return new InMemoryAsset(asset, output.getBytes(charset)); } @Override public String toString() { return fileset.toString(); } private Stream<String> patterns(final Predicate<String> filter) { return fileset.values().stream() .flatMap(List::stream) .filter(filter) .map(path -> path.split("/")[1]); } private String compile(final List<AssetProcessor> pipeline, final Iterator<String> files, final MediaType type, final String sep) throws Exception { StringBuilder buff = new StringBuilder(); while (files.hasNext()) { String file = files.next(); buff.append(compile(pipeline, file, type, readFile(loader, file, charset))).append(sep); } return buff.toString(); } private String compile(final List<AssetProcessor> pipeline, final String filename, final MediaType type, final String input) throws Exception { log.info(" {}", filename); Iterator<AssetProcessor> it = pipeline.iterator(); String contents = input; while (it.hasNext()) { AssetProcessor processor = it.next(); if (processor.matches(type) && !processor.excludes(filename)) { String pname = processor.name(); long start = System.currentTimeMillis(); try { log.debug(" executing: {}", pname); contents = processor.process(filename, contents, conf); } finally { long end = System.currentTimeMillis(); log.debug(" {} took {}ms", pname, end - start); } } } return contents; } private String sha1(final String source) { return BaseEncoding.base16() .encode(Hashing .sha1() .hashString(source, charset) .asBytes()) .substring(0, 8).toLowerCase(); } private static String readFile(final ClassLoader loader, final String path, final Charset charset) throws IOException { String spath = path.startsWith("/") ? path.substring(1) : path; InputStream resource = loader.getResourceAsStream(spath); if (resource == null) { throw new FileNotFoundException(path); } return toString(resource, charset); } private static String toString(final InputStream in, final Charset charset) throws IOException { try { return new String(ByteStreams.toByteArray(in), charset); } finally { Closeables.closeQuietly(in); } } private static Predicate<String> predicate(final Config fileset, final String... extension) { String path = "assets" + extension[0]; Set<String> extensions = new HashSet<>(); extensions.addAll(Arrays.asList(extension)); if (fileset.hasPath(path)) { extensions.addAll(strlist(fileset.getAnyRef(path))); } return file -> { for (String ext : extensions) { if (file.endsWith(ext)) { return true; } } return false; }; } private static Map<String, List<String>> fileset(final ClassLoader loader, final String basedir, final Config conf, final Consumer<AssetAggregator> aggregators) { Map<String, List<String>> raw = new HashMap<>(); Map<String, List<String>> graph = new HashMap<>(); Config assetconf = conf.getConfig("assets"); Config fileset = assetconf.getConfig("fileset"); // 1st pass, collect single resources (no merge) fileset.entrySet().forEach(e -> { List<String> key = Splitter.on('<') .trimResults() .omitEmptyStrings() .splitToList(unquote(e.getKey())); List<String> candidates = strlist(e.getValue().unwrapped(), v -> basedir + spath(v)); List<String> values = new ArrayList<>(); candidates.forEach(it -> { Try.run(() -> { processors(assetconf, loader, null, ImmutableList.of(it.substring(1)), ImmutableSet.of()) .stream().filter(instanceOf(AssetAggregator.class)) .forEach(p -> { AssetAggregator a = (AssetAggregator) p; aggregators.accept(a); a.fileset().forEach(f -> values.add(spath(f))); }); }).onFailure(x -> values.add(it)); }); raw.put(key.get(0), values); graph.put(key.get(0), key); }); Map<String, List<String>> resolved = new HashMap<>(); graph.forEach((fs, deps) -> { resolve(fs, deps, raw, graph, resolved); }); return resolved; } private static List<String> resolve(final String fs, final List<String> deps, final Map<String, List<String>> raw, final Map<String, List<String>> graph, final Map<String, List<String>> resolved) { List<String> result = resolved.get(fs); if (result == null) { result = new ArrayList<>(); resolved.put(fs, result); for (int i = deps.size() - 1; i > 0; i--) { result.addAll(resolve(deps.get(i), graph.get(deps.get(i)), raw, graph, resolved)); } result.addAll(raw.get(fs)); } return result; } private static String unquote(final String key) { return key.replace("\"", ""); } private static Map<String, List<AssetProcessor>> pipeline(final ClassLoader loader, final Config conf) throws Exception { Map<String, List<AssetProcessor>> processors = new HashMap<>(); processors.put("dev", Collections.emptyList()); if (conf.hasPath("pipeline")) { Set<String> filter = conf.getConfig("pipeline").entrySet().stream() .map(e -> e.getKey()) .collect(Collectors.toSet()); filter.add("class"); Set<Entry<String, ConfigValue>> entrySet = conf.getConfig("pipeline").entrySet(); for (Entry<String, ConfigValue> entry : entrySet) { String env = unquote(entry.getKey()); processors.put(env, processors(conf, loader, env, strlist(entry.getValue().unwrapped()), filter)); } } return processors; } @SuppressWarnings("unchecked") private static <T extends AssetOptions> List<T> processors(final Config conf, final ClassLoader loader, final String env, final List<String> names, final Set<String> filter) throws Exception { Map<String, Class<AssetOptions>> classes = new LinkedHashMap<>(); for (Entry<String, String> entry : bind(conf, names).entrySet()) { classes.put(entry.getKey(), (Class<AssetOptions>) loader.loadClass(entry.getValue())); } return (List<T>) processors(conf, env, filter, classes); } @SuppressWarnings("unchecked") private static <T extends AssetOptions> List<T> processors(final Config conf, final String env, final Set<String> filter, final Map<String, Class<T>> classes) throws Exception { List<T> processors = new ArrayList<>(); Function<Config, Config> without = options -> { for (String path : filter) { options = options.withoutPath(path); } return options; }; for (Entry<String, Class<T>> entry : classes.entrySet()) { String name = entry.getKey(); Class<T> clazz = entry.getValue(); Config options = ConfigFactory.empty(); if (conf.hasPath(name)) { options = conf.getConfig(name); if (env != null && options.hasPath(env)) { options = options.getConfig(env).withFallback(options); } } AssetOptions processor = clazz.newInstance(); processor.set(without.apply(options)); processors.add((T) processor); } return processors; } private static Map<String, String> bind(final Config conf, final List<String> names) { Map<String, String> map = new LinkedHashMap<>(); names.forEach(name -> { String clazz = AssetCompiler.class.getPackage().getName() + "." + CaseFormat.LOWER_HYPHEN.to(CaseFormat.UPPER_CAMEL, name); if (conf.hasPath(name + ".class")) { clazz = conf.getString(name + ".class"); } map.put(name, clazz); }); return map; } private static List<String> strlist(final Object value) { return strlist(value, v -> v); } @SuppressWarnings("unchecked") private static List<String> strlist(final Object value, final Function<String, String> mapper) { ImmutableList.Builder<String> list = ImmutableList.builder(); if (value instanceof Collection) { ((Collection<? extends String>) value).forEach(v -> list.add(mapper.apply(v))); } else { list.add(mapper.apply(value.toString())); } return list.build(); } private static String spath(final String path) { return path.charAt(0) == '/' ? path : "/" + path; } // http://stackoverflow.com/questions/3758606/how-to-convert-byte-size-into-human-readable-format-in-java static String humanReadableByteCount(final long bytes) { int unit = 1024; if (bytes < unit) { return bytes + "b"; } int exp = (int) (Math.log(bytes) / Math.log(unit)); char pre = "kmgtpe".charAt(exp - 1); return String.format("%.1f%sb", bytes / Math.pow(unit, exp), pre); } }