From fff71d1140b91ba49b75994724f954a8230531e8 Mon Sep 17 00:00:00 2001
From: Minecon724 <git@m724.eu>
Date: Tue, 4 Mar 2025 14:19:31 +0100
Subject: [PATCH] Asset remapping / Cache buster

Signed-off-by: Minecon724 <git@m724.eu>
---
 example_workdir/assets/hello.txt              |   2 +-
 example_workdir/render.yml                    |   9 +-
 example_workdir/template/index_template.html  |   5 +
 src/main/java/eu/m724/blog/BlogBuilder.java   |  50 ++++++--
 .../eu/m724/blog/StaticCacheRemapper.java     | 112 ++++++++++++++++++
 src/main/java/eu/m724/blog/data/Post.java     |   2 +-
 .../java/eu/m724/blog/data/RenderOptions.java |  26 ++--
 .../m724/blog/template/TemplateExtension.java |  23 +++-
 .../m724/blog/template/TemplateRenderer.java  |   7 +-
 9 files changed, 201 insertions(+), 35 deletions(-)
 create mode 100644 src/main/java/eu/m724/blog/StaticCacheRemapper.java

diff --git a/example_workdir/assets/hello.txt b/example_workdir/assets/hello.txt
index 78ca7fd..5dd01c1 100644
--- a/example_workdir/assets/hello.txt
+++ b/example_workdir/assets/hello.txt
@@ -1 +1 @@
-this is a static asset
\ No newline at end of file
+Hello, world!
\ No newline at end of file
diff --git a/example_workdir/render.yml b/example_workdir/render.yml
index 68d7c62..7c74d0b 100644
--- a/example_workdir/render.yml
+++ b/example_workdir/render.yml
@@ -1,5 +1,12 @@
 # Render options here
 
+# Pre-compress files to serve with web server software
 compress:
   - gz
-  - zstd
\ No newline at end of file
+  - zstd
+
+# Add .hash. to static assets provided by template
+remapTemplateStatic: true
+
+# Add .hash. to site static assets
+remapAssets: false
diff --git a/example_workdir/template/index_template.html b/example_workdir/template/index_template.html
index d49b62b..ebf9bee 100644
--- a/example_workdir/template/index_template.html
+++ b/example_workdir/template/index_template.html
@@ -25,5 +25,10 @@
             <li>{{ e }}</li>
             {% endfor %}
         </ul>
+
+        <a href="{{ asset('hello.txt') }}">This is an asset that says:</a>
+        <blockquote>Hello, world!</blockquote>
+
+        <a href="{{ asset('another.txt') }}">This is another asset that says things about assets.</a>
     </body>
 </html>
\ No newline at end of file
diff --git a/src/main/java/eu/m724/blog/BlogBuilder.java b/src/main/java/eu/m724/blog/BlogBuilder.java
index a523d4e..33fd76b 100644
--- a/src/main/java/eu/m724/blog/BlogBuilder.java
+++ b/src/main/java/eu/m724/blog/BlogBuilder.java
@@ -17,10 +17,8 @@ import java.io.IOException;
 import java.nio.file.FileAlreadyExistsException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.stream.Collectors;
 
 /**
@@ -132,12 +130,11 @@ public class BlogBuilder {
         if (renderOptions == null)
             this.renderOptions = RenderOptions.fromConfig(workingDirectory.resolve("render.yml"));
 
-        if (template == null)
-            this.template = new TemplateRenderer(site, templateDirectory);
+        LOGGER.debug("Copying static assets...");
+        var fileHashes = copyStaticAssets();
 
-        LOGGER.debug("Copying assets...");
-        copyTree(workingDirectory.resolve("assets"), outputDirectory.resolve("assets"));
-        copyTree(templateDirectory.resolve("static"), outputDirectory.resolve("static"));
+        if (template == null)
+            this.template = new TemplateRenderer(site, templateDirectory, fileHashes);
 
         LOGGER.debug("Rendering posts...");
         var posts = renderPosts();
@@ -203,6 +200,41 @@ public class BlogBuilder {
         return posts;
     }
 
+    private Map<String, String> copyStaticAssets() throws IOException {
+        var fileHashes = new HashMap<String, String>();
+
+        if (renderOptions.remapAssets()) {
+            var remapper = StaticCacheRemapper.fromGitRepository(workingDirectory);
+            if (remapper == null) {
+                LOGGER.warn("Site should be a Git directory"); // TODO like it isn't?
+                remapper = new StaticCacheRemapper(Integer.toHexString(ThreadLocalRandom.current().nextInt()));
+            }
+
+            var assetHashes = remapper.copyTree(workingDirectory.resolve("assets"), outputDirectory.resolve("assets"));
+            assetHashes.forEach((k, v) -> {
+                fileHashes.put("assets/" + k, v); // TODO this seems like a hack
+            });
+        } else {
+            copyTree(workingDirectory.resolve("assets"), outputDirectory.resolve("assets"));
+        }
+
+        if (renderOptions.remapTemplateStatic()) {
+            var remapper = StaticCacheRemapper.fromGitRepository(templateDirectory);
+            if (remapper == null) {
+                LOGGER.warn("Template should be a Git directory");
+                remapper = new StaticCacheRemapper(Integer.toHexString(ThreadLocalRandom.current().nextInt()));
+            }
+            var templateStaticHashes = remapper.copyTree(templateDirectory.resolve("static"), outputDirectory.resolve("static"));
+            templateStaticHashes.forEach((k, v) -> {
+                fileHashes.put("static/" + k, v); // TODO this seems like a hack
+            });
+        } else {
+            copyTree(templateDirectory.resolve("static"), outputDirectory.resolve("static"));
+        }
+
+        return fileHashes;
+    }
+
     private void compressOutput() throws IOException {
         var compressors = renderOptions.compress().stream()
                 .map(FileCompressor::new)
diff --git a/src/main/java/eu/m724/blog/StaticCacheRemapper.java b/src/main/java/eu/m724/blog/StaticCacheRemapper.java
new file mode 100644
index 0000000..db95a84
--- /dev/null
+++ b/src/main/java/eu/m724/blog/StaticCacheRemapper.java
@@ -0,0 +1,112 @@
+package eu.m724.blog;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.RepositoryBuilder;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class StaticCacheRemapper {
+    private final String revision;
+
+    public StaticCacheRemapper(String revision) {
+        this.revision = revision;
+    }
+
+    public String getRevision() {
+        return revision;
+    }
+
+    /**
+     * Creates a {@link StaticCacheRemapper} from a static files folder
+     * @param path the {@link Path} that points to the folder with the static files
+     * @return null if no Git repository at {@code path}
+     */
+    public static StaticCacheRemapper fromGitRepository(Path path) {
+        path = path.toAbsolutePath();
+
+        var builder = new RepositoryBuilder()
+                .findGitDir(path.toFile());
+
+        if (builder.getGitDir() != null) {
+            var relativePath = builder.getGitDir().toPath().getParent().relativize(path);
+
+            try (
+                    var repository = builder.build();
+                    var git = new Git(repository)
+            ) {
+                var log = git.log().addPath(relativePath.toString()).call();
+                var commit = log.iterator().next();
+
+                if (commit != null) {
+                    var commitIdShort = commit.getId().getName().substring(0, 10); // TODO maybe less than 10 is ok
+                    return new StaticCacheRemapper(commitIdShort);
+                }
+            } catch (GitAPIException | IOException e) {
+                // TODO do something about it
+            }
+        }
+
+        return null;
+    }
+
+    public Map<String, String> copyTree(Path srcDir, Path destDir) throws IOException {
+        var map = new HashMap<String, String>();
+
+        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);
+                    }
+
+                    var fileName = dest.getFileName().toString();
+                    fileName = insertHashInPath(fileName, revision);
+                    dest = dest.resolveSibling(fileName);
+
+                    Files.copy(src, dest);
+                    map.put(rel.toString(), revision);
+                }
+            }
+        }
+
+        return map;
+    }
+
+    /**
+     * Inserts a hash string before the file extension in a path.
+     * Example: insertHashInPath("a/path/like.this", "abc123") returns "a/path/like.abc123.this"
+     *
+     * @param path The original file path
+     * @param hash The hash to insert before the file extension
+     * @return The path with the hash inserted before the extension
+     */
+    public static String insertHashInPath(String path, String hash) {
+        if (path == null || path.isEmpty() || hash == null) {
+            return path;
+        }
+
+        int lastDotIndex = path.lastIndexOf('.');
+
+        // If there's no extension, just append the hash
+        if (lastDotIndex == -1) {
+            return path + "." + hash;
+        }
+
+        // Insert the hash before the extension
+        String basePath = path.substring(0, lastDotIndex);
+        String extension = path.substring(lastDotIndex + 1);
+
+        return basePath + "." + hash + "." + extension;
+    }
+}
diff --git a/src/main/java/eu/m724/blog/data/Post.java b/src/main/java/eu/m724/blog/data/Post.java
index 108c6b2..7c6e77e 100644
--- a/src/main/java/eu/m724/blog/data/Post.java
+++ b/src/main/java/eu/m724/blog/data/Post.java
@@ -126,7 +126,7 @@ public record Post(
             }
         } catch (GitAPIException e) {
             draft = true;
-            LOGGER.warn("[Post {}] Draft because of a Git exception: {}\n", slug, e.getMessage());
+            LOGGER.warn("[Post {}] Draft because of a Git exception: {}", slug, e.getMessage());
         }
 
         return new Post(slug, title, summary, draft, revisions, createdBy, createdAt, modifiedBy, modifiedAt, custom, content);
diff --git a/src/main/java/eu/m724/blog/data/RenderOptions.java b/src/main/java/eu/m724/blog/data/RenderOptions.java
index 19f7e7e..cd50df4 100644
--- a/src/main/java/eu/m724/blog/data/RenderOptions.java
+++ b/src/main/java/eu/m724/blog/data/RenderOptions.java
@@ -13,10 +13,11 @@ import java.util.List;
 import java.util.Map;
 
 public record RenderOptions(
-        List<String> compress // TODO rename?
-) {
-    private static final Logger LOGGER = LoggerFactory.getLogger(RenderOptions.class);
+        List<String> compress, // TODO rename?
 
+        boolean remapTemplateStatic,
+        boolean remapAssets
+) {
     /**
      * Creates a {@link Site} object by reading and parsing the configuration file at the specified path.<br>
      * The configuration file must be a JSON file.
@@ -29,22 +30,13 @@ public record RenderOptions(
         var load = new Load(LoadSettings.builder().build());
         var yaml = (Map<String, Object>) load.loadFromInputStream(Files.newInputStream(path));
 
-        List<String> compress = new ArrayList<>();
-
-        for (var key : yaml.keySet()) {
-            var value = yaml.get(key);
-
-            switch (key) {
-                case "compress":
-                    compress = (List<String>) value;
-                    break;
-                default:
-                    LOGGER.warn("Ignoring unrecognized render option: {}", key);
-            }
-        }
+        List<String> compress = (List<String>) yaml.getOrDefault("compress", new ArrayList<>());
+        boolean remapTemplateStatic = (boolean) yaml.getOrDefault("remapTemplateStatic", true);
+        // assets are not remapped by default, because they might be hotlinked
+        boolean remapAssets = (boolean) yaml.getOrDefault("remapAssets", false);
 
         return new RenderOptions(
-                compress
+                compress, remapTemplateStatic, remapAssets
         );
     }
 }
diff --git a/src/main/java/eu/m724/blog/template/TemplateExtension.java b/src/main/java/eu/m724/blog/template/TemplateExtension.java
index d5e3923..4eb8902 100644
--- a/src/main/java/eu/m724/blog/template/TemplateExtension.java
+++ b/src/main/java/eu/m724/blog/template/TemplateExtension.java
@@ -1,5 +1,6 @@
 package eu.m724.blog.template;
 
+import eu.m724.blog.StaticCacheRemapper;
 import eu.m724.blog.data.Site;
 import io.pebbletemplates.pebble.extension.AbstractExtension;
 import io.pebbletemplates.pebble.extension.Function;
@@ -11,9 +12,11 @@ import java.util.Map;
 
 public class TemplateExtension extends AbstractExtension {
     private final Site site;
+    private final Map<String, String> fileHashes;
 
-    public TemplateExtension(Site site) {
+    public TemplateExtension(Site site, Map<String, String> fileHashes) {
         this.site = site;
+        this.fileHashes = fileHashes;
     }
 
     @Override
@@ -27,7 +30,14 @@ public class TemplateExtension extends AbstractExtension {
 
                     @Override
                     public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
-                        return site.directory() + "/static/" + args.get("path");
+                        var path = "static/" + args.get("path");
+                        var hash = fileHashes.get(path);
+
+                        if (hash != null) {
+                            path = StaticCacheRemapper.insertHashInPath(path, hash);
+                        }
+
+                        return site.directory() + "/" + path;
                     }
                 },
                 "asset", new Function() {
@@ -38,7 +48,14 @@ public class TemplateExtension extends AbstractExtension {
 
                     @Override
                     public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
-                        return site.directory() + "/assets/" + args.get("path");
+                        var path = "assets/" + args.get("path");
+                        var hash = fileHashes.get(path);
+
+                        if (hash != null) {
+                            path = StaticCacheRemapper.insertHashInPath(path, hash);
+                        }
+
+                        return site.directory() + "/" + path;
                     }
                 }
                 // TODO make url_for that supports relative and absolute paths
diff --git a/src/main/java/eu/m724/blog/template/TemplateRenderer.java b/src/main/java/eu/m724/blog/template/TemplateRenderer.java
index 72e8066..b95144a 100644
--- a/src/main/java/eu/m724/blog/template/TemplateRenderer.java
+++ b/src/main/java/eu/m724/blog/template/TemplateRenderer.java
@@ -24,16 +24,17 @@ public class TemplateRenderer {
      * Constructs a TemplateRenderer instance for rendering templates from the specified directory.
 
      * @param site the {@link Site} this renderer renders
-     * @param templateDirectory the root directory containing the template files
+     * @param templateDirectory the root directory containing the template file
+     * @param fileHashes file hashes. currently only applies to assets
      */
-    public TemplateRenderer(Site site, Path templateDirectory) {
+    public TemplateRenderer(Site site, Path templateDirectory, Map<String, String> fileHashes) {
         var loader = new FileLoader();
         loader.setPrefix(templateDirectory.toString());
         loader.setSuffix(".html");
 
         var pebbleEngine = new PebbleEngine.Builder()
                 .loader(loader)
-                .extension(new TemplateExtension(site))
+                .extension(new TemplateExtension(site, fileHashes))
                 .build();
 
         this.site = site;