blog-software-java/src/main/java/eu/m724/blog/BlogBuilder.java
Minecon724 053ce27f3c
Some checks are pending
/ build (push) Waiting to run
License
Signed-off-by: Minecon724 <git@m724.eu>
2025-03-06 17:54:50 +01:00

276 lines
9.4 KiB
Java

/*
* 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 eu.m724.blog.compress.FileCompressor;
import eu.m724.blog.object.Feed;
import eu.m724.blog.object.Post;
import eu.m724.blog.object.RenderOptions;
import eu.m724.blog.object.Site;
import eu.m724.blog.template.TemplateRenderer;
import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.io.file.PathUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.lib.RepositoryBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Collectors;
/**
* The {@code BlogBuilder} class facilitates building a static blog by managing templates,
* assets, posts, and rendering output files. It uses a Git repository as the
* source for the blog's content and configuration.
*/
public class BlogBuilder {
private static final Logger LOGGER = LoggerFactory.getLogger(BlogBuilder.class);
private final Git git;
private final Path workingDirectory;
private Site site;
private TemplateRenderer template;
private RenderOptions renderOptions;
private Path templateDirectory;
private Path outputDirectory;
private boolean renderDrafts = false;
/**
* Constructs a {@link BlogBuilder} instance using the provided Git repository.
*
* @param git the Git repository to be used for the blog.
*/
public BlogBuilder(Git git) {
this.git = git;
this.workingDirectory = git.getRepository().getDirectory().toPath().getParent();
this.templateDirectory = workingDirectory.resolve("template");
this.outputDirectory = workingDirectory.resolve("generated_out");
}
/**
* Creates a new {@link BlogBuilder} instance for the specified working directory.
* The directory is expected to be a Git repository.
*
* @param workingDirectory the root path of the blog, which must contain a Git repository.
* @return a {@link BlogBuilder} instance
* @throws IOException if there is an error accessing the Git repository
*/
public static BlogBuilder fromPath(Path workingDirectory) throws IOException {
var repository = new RepositoryBuilder()
.setGitDir(workingDirectory.resolve(".git").toFile())
.build();
var git = new Git(repository);
//
return new BlogBuilder(git);
}
/**
* Sets the directory to be used for templates in the blog build process.
*
* @param templateDirectory the path to the template directory
* @return the current instance of {@link BlogBuilder}
*/
public BlogBuilder templateDirectory(Path templateDirectory) {
this.templateDirectory = templateDirectory;
return this;
}
/**
* Sets the directory where the output files will be saved during the blog build process.
*
* @param outputDirectory the path representing the directory for output files
* @return the current instance of {@link BlogBuilder}
*/
public BlogBuilder outputDirectory(Path outputDirectory) {
this.outputDirectory = outputDirectory;
return this;
}
/**
* Configures whether drafts should be rendered in the blog build process.
*
* @param renderDrafts a boolean flag indicating whether to include drafts in the rendering process.
* @return the current instance of {@link BlogBuilder}
*/
public BlogBuilder renderDrafts(boolean renderDrafts) {
this.renderDrafts = renderDrafts;
return this;
}
public void mkdirs(boolean force) throws IOException {
if (outputDirectory.toFile().exists()) {
if (force) {
PathUtils.deleteDirectory(outputDirectory);
} else {
throw new FileAlreadyExistsException(outputDirectory.toString(), null, "Output directory already exists. --force?");
}
}
Files.createDirectory(outputDirectory);
}
/**
* Builds the blog by generating templates, copying assets, and rendering posts.
*
* @throws IOException if an I/O error occurs
*/
public void build() throws IOException {
LOGGER.debug("Loading site...");
if (site == null)
this.site = Site.fromConfig(workingDirectory.resolve("site.yml"));
if (renderOptions == null)
this.renderOptions = RenderOptions.fromConfig(workingDirectory.resolve("render.yml"));
LOGGER.debug("Copying static assets...");
var fileHashes = copyStaticAssets();
if (template == null)
this.template = new TemplateRenderer(site, templateDirectory, fileHashes);
LOGGER.debug("Rendering posts...");
var posts = renderPosts();
LOGGER.debug("Rendering meta...");
posts.sort(Comparator.comparing(Post::createdAt).reversed());
Files.writeString(outputDirectory.resolve("index.html"), template.renderIndex(posts));
Files.writeString(outputDirectory.resolve("posts.rss"), Feed.generateRss(site, posts));
if (!renderOptions.compress().isEmpty()) {
LOGGER.debug("Compressing...");
compressOutput();
}
}
// TODO should server be here in builder really
public void startServer(boolean openBrowser) throws IOException {
var server = new Server(outputDirectory, site.directory());
server.start();
if (openBrowser) {
server.openBrowser();
}
}
private List<Post> renderPosts() throws IOException {
Files.createDirectory(outputDirectory.resolve("post"));
var postDirectory = workingDirectory.resolve("posts");
var posts = new ArrayList<Post>();
try (var stream = Files.walk(postDirectory)) {
for (var path : stream.collect(Collectors.toSet())) {
if (!Files.isRegularFile(path))
continue; // directory is created below
if (!path.toString().endsWith(".html")) {
LOGGER.warn("Post {}: unsupported file type", path.getFileName());
continue;
}
path = postDirectory.relativize(path);
var post = Post.fromFile(git, path);
if (post.draft() && !renderDrafts) {
LOGGER.info("Post {}: draft, ignoring", path.getFileName());
continue;
}
var render = template.renderPost(post);
var outFile = outputDirectory.resolve("post").resolve(path);
try {
Files.createDirectory(outFile.getParent());
} catch (FileAlreadyExistsException ignored) { }
Files.writeString(outFile, render);
posts.add(post);
}
}
return posts;
}
private Map<String, String> copyStaticAssets() throws IOException {
var fileHashes = new HashMap<String, String>();
if (renderOptions.remapAssets()) {
var assetHashes = CacheBuster.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 templateStaticHashes = CacheBuster.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)
.toList();
Set<Path> tree;
try (var walk = Files.walk(outputDirectory)) {
tree = walk.filter(Files::isRegularFile).collect(Collectors.toSet());
}
for (var compressor : compressors) {
for (var path : tree) {
try {
compressor.compress(path);
} catch (CompressorException e) {
LOGGER.error("Error compressing {} to {}: {}", path, compressor.getAlgorithm(), e.getMessage());
}
}
}
}
/* 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);
}
}
}
}
}