diff --git a/pom.xml b/pom.xml index f1cf015..008547e 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,13 @@ slf4j-simple 2.0.17 + + + + in.wilsonl.minifyhtml + minify-html + 0.15.0 + @@ -127,6 +134,7 @@ com.github.luben:zstd-jni + in.wilsonl.minifyhtml:minify-html diff --git a/src/main/java/eu/m724/blog/BlogBuilder.java b/src/main/java/eu/m724/blog/BlogBuilder.java index b793f3d..31c2144 100644 --- a/src/main/java/eu/m724/blog/BlogBuilder.java +++ b/src/main/java/eu/m724/blog/BlogBuilder.java @@ -41,6 +41,7 @@ public class BlogBuilder { private Site site; private TemplateRenderer template; private RenderOptions renderOptions; + private Minifier minifier; private Path templateDirectory; private Path outputDirectory; @@ -136,11 +137,19 @@ public class BlogBuilder { if (renderOptions == null) this.renderOptions = RenderOptions.fromConfig(workingDirectory.resolve("render.yml")); + if (this.site.minify()) { + try { + this.minifier = new Minifier(); + } catch (NoClassDefFoundError e) { + LOGGER.warn("Minifier not available"); + } + } + LOGGER.debug("Copying static assets..."); var fileHashes = copyStaticAssets(); if (template == null) - this.template = new TemplateRenderer(site, templateDirectory, fileHashes); + this.template = new TemplateRenderer(site, minifier, templateDirectory, fileHashes); LOGGER.debug("Rendering articles..."); var articles = renderArticles(); @@ -210,24 +219,36 @@ public class BlogBuilder { private Map copyStaticAssets() throws IOException { var fileHashes = new HashMap(); + var userAssetsDir = workingDirectory.resolve("assets"); + var templateStaticDir = templateDirectory.resolve("static"); + + var outputUserAssetsDir = outputDirectory.resolve("assets"); + var outputTemplateStaticDir = outputDirectory.resolve("static"); + + if (renderOptions.remapAssets()) { - var assetHashes = CacheBuster.copyTree(workingDirectory.resolve("assets"), outputDirectory.resolve("assets")); + var assetHashes = CacheBuster.copyTree(userAssetsDir, outputUserAssetsDir); assetHashes.forEach((k, v) -> { - fileHashes.put("assets/" + k, v); // TODO this seems like a hack + fileHashes.put("assets/" + k, v); // TODO this feels like a hack }); } else { - copyTree(workingDirectory.resolve("assets"), outputDirectory.resolve("assets")); + FileUtils.copyTree(userAssetsDir, outputUserAssetsDir); } if (renderOptions.remapTemplateStatic()) { - var templateStaticHashes = CacheBuster.copyTree(templateDirectory.resolve("static"), outputDirectory.resolve("static")); + var templateStaticHashes = CacheBuster.copyTree(templateStaticDir, outputTemplateStaticDir); templateStaticHashes.forEach((k, v) -> { - fileHashes.put("static/" + k, v); // TODO this seems like a hack + fileHashes.put("static/" + k, v); // TODO this feels like a hack }); } else { - copyTree(templateDirectory.resolve("static"), outputDirectory.resolve("static")); + FileUtils.copyTree(templateStaticDir, outputTemplateStaticDir); + } + + + if (minifier != null) { + minifier.minifyTree(outputTemplateStaticDir); } return fileHashes; @@ -261,26 +282,4 @@ public class BlogBuilder { } } } - - - /* Internal functions */ - - private void copyTree(Path srcDir, Path destDir) throws IOException { - try (var walk = Files.walk(srcDir)) { - for (var src : walk.collect(Collectors.toSet())) { - var rel = srcDir.relativize(src); - var dest = destDir.resolve(rel); - - if (Files.isRegularFile(src)) { - var parent = dest.getParent(); - - if (!Files.isDirectory(parent)) { - Files.createDirectories(parent); - } - - Files.copy(src, dest); - } - } - } - } } diff --git a/src/main/java/eu/m724/blog/CacheBuster.java b/src/main/java/eu/m724/blog/CacheBuster.java index 917eb2e..2390733 100644 --- a/src/main/java/eu/m724/blog/CacheBuster.java +++ b/src/main/java/eu/m724/blog/CacheBuster.java @@ -11,7 +11,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; -import java.util.stream.Collectors; import java.util.zip.CRC32C; public class CacheBuster { @@ -19,27 +18,17 @@ public class CacheBuster { public static Map copyTree(Path srcDir, Path destDir) throws IOException { var map = new HashMap(); - try (var walk = Files.walk(srcDir)) { - for (var src : walk.collect(Collectors.toSet())) { - var rel = srcDir.relativize(src); - var dest = destDir.resolve(rel); + for (var e : FileUtils.srcDestMap(srcDir, destDir).entrySet()) { + var sourceFile = e.getKey(); + var destinationFile = e.getValue(); - if (Files.isRegularFile(src)) { - var parent = dest.getParent(); + var hash = hashFile(sourceFile); - if (!Files.isDirectory(parent)) { - Files.createDirectories(parent); - } + var filename = insertHashInPath(destinationFile.getFileName().toString(), hash); + destinationFile = destinationFile.resolveSibling(filename); - var hash = hashFile(src); - - var filename = insertHashInPath(dest.getFileName().toString(), hash); - dest = dest.resolveSibling(filename); - - Files.copy(src, dest); - map.put(rel.toString(), hash); - } - } + Files.copy(sourceFile, destinationFile); + map.put(srcDir.relativize(sourceFile).toString(), hash); } return map; diff --git a/src/main/java/eu/m724/blog/FileUtils.java b/src/main/java/eu/m724/blog/FileUtils.java new file mode 100644 index 0000000..b83362d --- /dev/null +++ b/src/main/java/eu/m724/blog/FileUtils.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 blog-software-java developers + * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file + * in the project root for the full license text. + */ + +package eu.m724.blog; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +public class FileUtils { + public static void copyTree(Path srcDir, Path destDir) throws IOException { + for (var e : srcDestMap(srcDir, destDir).entrySet()) { + Files.copy(e.getKey(), e.getValue()); + } + } + + public static Map srcDestMap(Path srcDir, Path destDir) throws IOException { + var map = new HashMap(); + + for (var sourceFile : walkFilesList(srcDir)) { + var rel = srcDir.relativize(sourceFile); + var destinationFile = destDir.resolve(rel); + + var parent = destinationFile.getParent(); + + if (!Files.isDirectory(parent)) { + Files.createDirectories(parent); + } + + map.put(sourceFile, destinationFile); + } + + return map; + } + + /** + * Walks over a directory and only returns files.
+ * IMPORTANT Don't forget to close the stream.
+ * + * @param start the starting file + * @return the {@link Stream} of {@link Path} + * @throws IOException if an I/O error is thrown when accessing the starting file. + * + * @see FileUtils#walkFilesList(Path) + */ + public static Stream walkFilesStream(Path start) throws IOException { + return Files.walk(start).filter(Files::isRegularFile); + } + + /** + * Walks over a directory and only returns files.
+ * + * @param start the starting file + * @return the {@link List} of {@link Path} + * @throws IOException if an I/O error is thrown when accessing the starting file. + * + * @see FileUtils#walkFilesStream(Path) + */ + public static List walkFilesList(Path start) throws IOException { + try (var stream = walkFilesStream(start)) { + return stream.toList(); + } + } +} diff --git a/src/main/java/eu/m724/blog/Minifier.java b/src/main/java/eu/m724/blog/Minifier.java new file mode 100644 index 0000000..f2c742b --- /dev/null +++ b/src/main/java/eu/m724/blog/Minifier.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 blog-software-java developers + * blog-software-java is licensed under the GNU General Public License. See the LICENSE.md file + * in the project root for the full license text. + */ + +package eu.m724.blog; + +import in.wilsonl.minifyhtml.Configuration; +import in.wilsonl.minifyhtml.MinifyHtml; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +public class Minifier { + private static final Set CONTENT_TYPES_TO_MINIFY = Set.of( + "text/html", + "text/css", + "text/javascript" + ); + + private final Configuration configuration = new Configuration.Builder() + .setMinifyJs(true) + .setMinifyCss(true) + .setKeepHtmlAndHeadOpeningTags(true) + .build(); + + public String minify(String text) { + return MinifyHtml.minify(text, configuration); + } + + public void minifyTree(Path dir) throws IOException { + for (var file : FileUtils.walkFilesList(dir)) { + var contentType = Files.probeContentType(file); + var doMinify = CONTENT_TYPES_TO_MINIFY.contains(contentType); + + if (doMinify) { + var content = Files.readString(file); + var minified = minify(content); + Files.writeString(file, minified); + } + } + } + + public void copyAndMinifyTree(Path srcDir, Path destDir) throws IOException { + for (var e : FileUtils.srcDestMap(srcDir, destDir).entrySet()) { + var sourceFile = e.getKey(); + var destinationFile = e.getValue(); + + var contentType = Files.probeContentType(sourceFile); + var doMinify = CONTENT_TYPES_TO_MINIFY.contains(contentType); + + if (doMinify) { + var content = Files.readString(sourceFile); + var minified = minify(content); + Files.writeString(destinationFile, minified); + } else { + Files.copy(sourceFile, destDir); + } + } + } + +} diff --git a/src/main/java/eu/m724/blog/object/Site.java b/src/main/java/eu/m724/blog/object/Site.java index 4bb96ed..2b767f8 100644 --- a/src/main/java/eu/m724/blog/object/Site.java +++ b/src/main/java/eu/m724/blog/object/Site.java @@ -21,6 +21,7 @@ import java.util.Map; * @param baseUrl the base URL of the site * @param directory The directory that the site is installed into. Like "https://example.com/blog" turns to "/blog" * @param templateArticles whether to parse articles with Pebble templating + * @param minify whether to minify HTML, CSS, JS, etc. * @param custom a map of additional custom properties */ public record Site( @@ -30,6 +31,7 @@ public record Site( String directory, boolean templateArticles, + boolean minify, Map custom ) { @@ -48,6 +50,7 @@ public record Site( String name = (String) yaml.get("name"); String baseUrl = (String) yaml.getOrDefault("baseUrl", "/"); var templateArticles = (boolean) yaml.getOrDefault("templateArticles", false); + var minify = (boolean) yaml.getOrDefault("minify", true); String directory = "/"; if (baseUrl != null) { @@ -59,7 +62,7 @@ public record Site( } return new Site( - name, baseUrl, directory, templateArticles, yaml + name, baseUrl, directory, templateArticles, minify, yaml ); } } diff --git a/src/main/java/eu/m724/blog/template/TemplateRenderer.java b/src/main/java/eu/m724/blog/template/TemplateRenderer.java index 54d473c..a6e2fae 100644 --- a/src/main/java/eu/m724/blog/template/TemplateRenderer.java +++ b/src/main/java/eu/m724/blog/template/TemplateRenderer.java @@ -6,6 +6,7 @@ package eu.m724.blog.template; +import eu.m724.blog.Minifier; import eu.m724.blog.object.Article; import eu.m724.blog.object.Site; import io.pebbletemplates.pebble.PebbleEngine; @@ -24,6 +25,7 @@ import java.util.Map; */ public class TemplateRenderer { private final Site site; + private final Minifier minifier; private final PebbleEngine pebbleEngine; private final PebbleTemplate indexTemplate, articleTemplate; @@ -35,7 +37,11 @@ public class TemplateRenderer { * @param templateDirectory the root directory containing the template file * @param fileHashes file hashes. currently only applies to assets */ - public TemplateRenderer(Site site, Path templateDirectory, Map fileHashes) { + public TemplateRenderer(Site site, Minifier minifier, Path templateDirectory, Map fileHashes) { + this.site = site; + this.minifier = minifier; + + var loader = new FileLoader(); loader.setPrefix(templateDirectory.toString()); loader.setSuffix(".html"); @@ -45,7 +51,6 @@ public class TemplateRenderer { .extension(new TemplateExtension(site, fileHashes)) .build(); - this.site = site; this.indexTemplate = pebbleEngine.getTemplate("index_template"); this.articleTemplate = pebbleEngine.getTemplate("article_template"); } @@ -94,7 +99,12 @@ public class TemplateRenderer { private String renderTemplate(PebbleTemplate template, Map context) throws IOException { var writer = new StringWriter(); template.evaluate(writer, (Map) context); + var html = writer.toString(); - return writer.toString(); + if (minifier != null) { // not checking site.minify because the minifier may not be available + html = minifier.minify(html); + } + + return html; } }