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 @@
  • {{ e }}
  • {% endfor %} + + This is an asset that says: +
    Hello, world!
    + + This is another asset that says things about assets. \ 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 copyStaticAssets() throws IOException { + var fileHashes = new HashMap(); + + 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 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); + + 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 compress // TODO rename? -) { - private static final Logger LOGGER = LoggerFactory.getLogger(RenderOptions.class); + List compress, // TODO rename? + boolean remapTemplateStatic, + boolean remapAssets +) { /** * Creates a {@link Site} object by reading and parsing the configuration file at the specified path.
    * 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) load.loadFromInputStream(Files.newInputStream(path)); - List compress = new ArrayList<>(); - - for (var key : yaml.keySet()) { - var value = yaml.get(key); - - switch (key) { - case "compress": - compress = (List) value; - break; - default: - LOGGER.warn("Ignoring unrecognized render option: {}", key); - } - } + List compress = (List) 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 fileHashes; - public TemplateExtension(Site site) { + public TemplateExtension(Site site, Map fileHashes) { this.site = site; + this.fileHashes = fileHashes; } @Override @@ -27,7 +30,14 @@ public class TemplateExtension extends AbstractExtension { @Override public Object execute(Map 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 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 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;