Refactoring, more abstraction

Signed-off-by: Minecon724 <git@m724.eu>
This commit is contained in:
Minecon724 2025-03-10 17:50:47 +01:00
parent d6c33b4992
commit 62eb9903c0
Signed by untrusted user who does not match committer: Minecon724
GPG key ID: 3CCC4D267742C8E8
15 changed files with 306 additions and 119 deletions

1
.idea/vcs.xml generated
View file

@ -2,6 +2,7 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/example_workdir" vcs="Git" />
<mapping directory="$PROJECT_DIR$/m724" vcs="Git" />
</component>
</project>

View file

@ -6,17 +6,19 @@
package eu.m724.blog;
import eu.m724.blog.compress.CommonsCompressor;
import eu.m724.blog.compress.CompressException;
import eu.m724.blog.compress.FileCompressor;
import eu.m724.blog.compress.NoSuchAlgorithmException;
import eu.m724.blog.object.Article;
import eu.m724.blog.object.Feed;
import eu.m724.blog.object.RenderOptions;
import eu.m724.blog.object.Site;
import eu.m724.blog.server.Server;
import eu.m724.blog.template.TemplateRenderer;
import org.apache.commons.compress.compressors.CompressorException;
import eu.m724.blog.vc.GitVersionControl;
import eu.m724.blog.vc.VersionControl;
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;
@ -29,18 +31,18 @@ import java.util.stream.Collectors;
/**
* The {@code BlogBuilder} class facilitates building a static blog by managing templates,
* assets, articles, and rendering output files. It uses a Git repository as the
* assets, articles, and rendering output files. It uses a version control (Git etc.) 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 Site site;
private final RenderOptions renderOptions;
private final VersionControl versionControl;
private final Path workingDirectory;
private Site site;
private TemplateRenderer template;
private RenderOptions renderOptions;
private Minifier minifier;
private Path templateDirectory;
@ -50,12 +52,14 @@ public class BlogBuilder {
/**
* Constructs a {@link BlogBuilder} instance using the provided Git repository.
*
* @param git the Git repository to be used for the blog.
* @param versionControl the version control repository to be used for the blog.
*/
public BlogBuilder(Git git) {
this.git = git;
private BlogBuilder(Site site, RenderOptions renderOptions, Path workingDirectory, VersionControl versionControl) {
this.site = site;
this.renderOptions = renderOptions;
this.versionControl = versionControl;
this.workingDirectory = git.getRepository().getDirectory().toPath().getParent();
this.workingDirectory = workingDirectory;
this.templateDirectory = workingDirectory.resolve("template");
this.outputDirectory = workingDirectory.resolve("generated_out");
}
@ -64,19 +68,19 @@ public class BlogBuilder {
* 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.
* @param directory the root path of the blog, which must be 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);
public static BlogBuilder fromGitRepository(Path directory) throws IOException {
return BlogBuilder.fromDirectory(directory, new GitVersionControl(directory));
}
//
public static BlogBuilder fromDirectory(Path directory, VersionControl versionControl) throws IOException {
var site = Site.fromConfig(directory.resolve("site.yml"));
var renderOptions = RenderOptions.fromConfig(directory.resolve("render.yml"));
return new BlogBuilder(git);
return new BlogBuilder(site, renderOptions, directory, versionControl);
}
/**
@ -130,14 +134,7 @@ public class BlogBuilder {
* @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"));
if (this.site.minify()) {
if (renderOptions.minify()) {
try {
this.minifier = new Minifier();
} catch (NoClassDefFoundError e) {
@ -193,8 +190,7 @@ public class BlogBuilder {
continue;
}
path = articleDirectory.relativize(path);
var article = Article.fromFile(git, path);
var article = Article.fromFile(versionControl, path);
if (article.draft() && !renderDrafts) {
LOGGER.info("[Article {}] Draft. Ignoring, because you didn't specify to render drafts.", path.getFileName());
@ -202,7 +198,7 @@ public class BlogBuilder {
}
var render = template.renderArticle(article);
var outFile = outputDirectory.resolve("article").resolve(path);
var outFile = outputDirectory.resolve("article").resolve(path.getFileName());
try {
Files.createDirectory(outFile.getParent());
@ -229,8 +225,9 @@ public class BlogBuilder {
if (renderOptions.remapAssets()) {
var assetHashes = CacheBuster.copyTree(userAssetsDir, outputUserAssetsDir);
// This looks weird, but it's necessary if we want to use a single map
assetHashes.forEach((k, v) -> {
fileHashes.put("assets/" + k, v); // TODO this feels like a hack
fileHashes.put("assets/" + k, v);
});
} else {
FileUtils.copyTree(userAssetsDir, outputUserAssetsDir);
@ -239,8 +236,9 @@ public class BlogBuilder {
if (renderOptions.remapTemplateStatic()) {
var templateStaticHashes = CacheBuster.copyTree(templateStaticDir, outputTemplateStaticDir);
// This looks weird, but it's necessary if we want to use a single map
templateStaticHashes.forEach((k, v) -> {
fileHashes.put("static/" + k, v); // TODO this feels like a hack
fileHashes.put("static/" + k, v);
});
} else {
FileUtils.copyTree(templateStaticDir, outputTemplateStaticDir);
@ -259,7 +257,7 @@ public class BlogBuilder {
for (var algorithm : renderOptions.compress()) {
try {
var compressor = new FileCompressor(algorithm);
var compressor = new CommonsCompressor(algorithm);
compressors.add(compressor);
} catch (NoSuchAlgorithmException e) {
LOGGER.error("No such compression algorithm, ignoring: {}", e.getAlgorithm());
@ -276,8 +274,8 @@ public class BlogBuilder {
for (var path : tree) {
try {
compressor.compress(path);
} catch (CompressorException e) {
LOGGER.error("Error compressing \"{}\" to \"{}\": {}", path, compressor.getAlgorithm(), e.getMessage());
} catch (CompressException e) {
LOGGER.error("Error compressing \"{}\" to \"{}\": {}", path, compressor.getAlgorithmName(), e.getMessage());
}
}
}

View file

@ -38,7 +38,10 @@ public class Main {
var start = System.nanoTime();
var builder = BlogBuilder.fromPath(workingDirectory)
LOGGER.debug("Loading site...");
var builder = BlogBuilder.fromGitRepository(workingDirectory)
.templateDirectory(templateDirectory)
.outputDirectory(outputDirectory)
.renderDrafts(renderDrafts);

View file

@ -0,0 +1,25 @@
/*
* 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 org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
public class YamlLoader {
private static final Load LOAD = new Load(LoadSettings.builder().build());
public static Map<String, Object> loadMap(Path file) throws IOException {
try (var inputStream = Files.newInputStream(file)) {
return (Map<String, Object>) LOAD.loadFromInputStream(inputStream);
}
}
}

View file

@ -0,0 +1,58 @@
/*
* 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.compress;
import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
public class CommonsCompressor extends FileCompressor {
/**
* Constructs a {@link FileCompressor} instance using the provided algorithm.
*
* @param algorithm The algorithm, for valid names see {@link CompressorStreamFactory}
* @throws NoSuchAlgorithmException If that algorithm is unavailable or the name is invalid.
*/
public CommonsCompressor(String algorithm) throws NoSuchAlgorithmException {
super(algorithm);
try {
var os = new CompressorStreamFactory().createCompressorOutputStream(algorithm, OutputStream.nullOutputStream());
os.close();
} catch (NoClassDefFoundError | CompressorException e) {
throw new NoSuchAlgorithmException(algorithm, e);
} catch (IOException e) {
throw new RuntimeException("Unexpected IOException closing test output stream", e);
}
}
@Override
public void compress(Path file) throws IOException, CompressException {
var destination = file.resolveSibling(file.getFileName() + "." + getAlgorithmName());
compress(file, destination);
}
@Override
public void compress(Path source, Path destination) throws IOException, CompressException {
if (Files.exists(destination))
throw new FileAlreadyExistsException(destination.toString());
try (
var outputStream = new CompressorStreamFactory()
.createCompressorOutputStream(getAlgorithmName(), Files.newOutputStream(destination))
) {
Files.copy(source, outputStream);
} catch (CompressorException e) {
throw new CompressException(e);
}
}
}

View file

@ -0,0 +1,14 @@
/*
* 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.compress;
// TODO really runtime exception?
public class CompressException extends RuntimeException {
public CompressException(Throwable cause) {
super(cause);
}
}

View file

@ -6,53 +6,20 @@
package eu.m724.blog.compress;
import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.*;
public class FileCompressor {
private final String algorithm;
public abstract class FileCompressor {
private final String algorithmName;
/**
* Constructs a {@link FileCompressor} instance using the provided algorithm.
*
* @param algorithm The algorithm, for valid names see {@link CompressorStreamFactory}
* @throws NoSuchAlgorithmException If that algorithm is unavailable or the name is invalid.
*/
public FileCompressor(String algorithm) throws NoSuchAlgorithmException {
this.algorithm = algorithm;
try {
var os = new CompressorStreamFactory().createCompressorOutputStream(algorithm, OutputStream.nullOutputStream());
os.close();
} catch (NoClassDefFoundError | CompressorException e) {
throw new NoSuchAlgorithmException(algorithm, e);
} catch (IOException e) {
throw new RuntimeException("Unexpected IOException closing test output stream", e);
}
protected FileCompressor(String algorithmName) {
this.algorithmName = algorithmName;
}
public void compress(Path source) throws IOException, CompressorException {
var destination = source.resolveSibling(source.getFileName() + "." + algorithm);
compress(source, destination);
public String getAlgorithmName() {
return algorithmName;
}
public void compress(Path source, Path destination) throws IOException, CompressorException {
if (Files.exists(destination))
throw new FileAlreadyExistsException(destination.toString());
try (
var outputStream = new CompressorStreamFactory()
.createCompressorOutputStream(algorithm, Files.newOutputStream(destination))
) {
Files.copy(source, outputStream);
}
}
public String getAlgorithm() {
return algorithm;
}
abstract public void compress(Path file) throws IOException, CompressException;
abstract public void compress(Path source, Path destination) throws IOException, CompressException;
}

View file

@ -6,8 +6,8 @@
package eu.m724.blog.object;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import eu.m724.blog.vc.VersionControl;
import eu.m724.blog.vc.VersionControlException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -56,17 +56,16 @@ public record Article(
* The method extracts metadata properties, content, and versioning information
* based on the Git history of the file.
*
* @param git the Git repository used to retrieve versioning and commit information
* @param versionControl the version control repository used to retrieve versioning and commit information
* @param path the relative path to the file within the "articles" directory
* @return a {@link Article} object populated with data extracted from the specified file
* @throws IOException if an error occurs during file reading
*/
public static Article fromFile(Git git, Path path) throws IOException {
public static Article fromFile(VersionControl versionControl, Path path) throws IOException {
/* read properties before filtering */
var slug = path.getFileName().toString().split("\\.")[0];
path = Path.of("articles").resolve(path);
var lines = Files.readAllLines(git.getRepository().getDirectory().toPath().getParent().resolve(path));
var lines = Files.readAllLines(path);
var properties = new HashMap<String, String>();
@ -121,18 +120,18 @@ public record Article(
ZonedDateTime modifiedAt = Instant.ofEpochMilli(0).atZone(ZoneOffset.UTC);
try {
for (var commit : git.log().addPath(path.toString()).call()) {
createdBy = commit.getAuthorIdent().getName();
createdAt = Instant.ofEpochSecond(commit.getCommitTime()).atZone(ZoneOffset.UTC);
for (var change : versionControl.getChanges(path)) {
createdBy = change.author();
createdAt = change.time();
if (revisions++ == 0) {
modifiedBy = createdBy;
modifiedAt = createdAt;
}
}
} catch (GitAPIException e) {
} catch (VersionControlException e) {
draft = true;
LOGGER.warn("[Article {}] Draft because of a Git exception: {}", slug, e.getMessage());
LOGGER.warn("[Article {}] Draft because of a VC exception: {}", slug, e.getMessage());
}
return new Article(slug, title, summary, draft, revisions, createdBy, createdAt, modifiedBy, modifiedAt, custom, content);

View file

@ -6,41 +6,61 @@
package eu.m724.blog.object;
import org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
import eu.m724.blog.YamlLoader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Options that are relevant to rendering, not about the {@link Site}.
*
* @param compress list of compression algorithms to compress output
* @param remapAssets whether to remap user assets, add a unique hash to the file name to bypass cache
* @param remapTemplateStatic whether to remap template static assets, add a unique hash to the file name to bypass cache
* @param minify whether to minify output
*/
public record RenderOptions(
List<String> compress, // TODO rename?
boolean remapAssets,
boolean remapTemplateStatic,
boolean remapAssets
boolean minify
) {
private static final RenderOptions DEFAULT = new RenderOptions(
List.of("gz", "zstd"),
false,
true,
true
);
/**
* Creates a {@link Site} object by reading and parsing the configuration file at the specified path.<br>
* Creates a {@link Site} object by reading and parsing the configuration file at the specified file.<br>
* The configuration file must be a JSON file.
*
* @param path the path to the configuration file
* @param file the file to the configuration file
* @return a {@link Site} object initialized with the data from the configuration file
* @throws IOException if an error occurs during file reading
*/
public static RenderOptions fromConfig(Path path) throws IOException {
var load = new Load(LoadSettings.builder().build());
var yaml = (Map<String, Object>) load.loadFromInputStream(Files.newInputStream(path));
public static RenderOptions fromConfig(Path file) throws IOException {
var yaml = YamlLoader.loadMap(file);
/* ---- */
List<String> compress = (List<String>) yaml.getOrDefault("compress", DEFAULT.compress());
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);
boolean remapAssets = (boolean) yaml.getOrDefault("remapAssets", DEFAULT.remapAssets());
boolean remapTemplateStatic = (boolean) yaml.getOrDefault("remapTemplateStatic", DEFAULT.remapTemplateStatic());
var minify = (boolean) yaml.getOrDefault("minify", DEFAULT.minify());
/* ---- */
return new RenderOptions(
compress, remapTemplateStatic, remapAssets
compress, remapTemplateStatic, remapAssets, minify
);
}
}

View file

@ -6,11 +6,9 @@
package eu.m724.blog.object;
import org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
import eu.m724.blog.YamlLoader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
@ -21,7 +19,6 @@ 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(
@ -31,28 +28,33 @@ public record Site(
String directory,
boolean templateArticles,
boolean minify,
Map<String, Object> custom
) {
private static final Site DEFAULT = new Site(
"Misconfigured blog",
"/",
"/",
false,
Map.of()
);
/**
* 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.
*
* @param path the path to the configuration file
* @param file the path to the configuration file
* @return a {@link Site} object initialized with the data from the configuration file
* @throws IOException if an error occurs during file reading
*/
public static Site fromConfig(Path path) throws IOException {
var load = new Load(LoadSettings.builder().build());
var yaml = (Map<String, Object>) load.loadFromInputStream(Files.newInputStream(path));
public static Site fromConfig(Path file) throws IOException {
var yaml = YamlLoader.loadMap(file);
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 name = (String) yaml.getOrDefault("name", DEFAULT.name());
String baseUrl = (String) yaml.getOrDefault("baseUrl", DEFAULT.baseUrl());
var templateArticles = (boolean) yaml.getOrDefault("templateArticles", DEFAULT.templateArticles());
String directory = "/";
String directory = DEFAULT.directory();
if (baseUrl != null) {
var temp = baseUrl.substring(baseUrl.indexOf(':') + 3);
var slashIndex = temp.indexOf('/');
@ -62,7 +64,7 @@ public record Site(
}
return new Site(
name, baseUrl, directory, templateArticles, minify, yaml
name, baseUrl, directory, templateArticles, yaml
);
}
}

View file

@ -4,7 +4,7 @@
* in the project root for the full license text.
*/
package eu.m724.blog;
package eu.m724.blog.server;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.SimpleFileServer;

View file

@ -0,0 +1,14 @@
/*
* 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.vc;
import java.time.ZonedDateTime;
public record Change(
String author,
ZonedDateTime time
) { }

View file

@ -0,0 +1,56 @@
/*
* 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.vc;
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.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
public class GitVersionControl implements VersionControl {
private final Git git;
private final Path directory;
public GitVersionControl(Path directory) throws IOException {
this.directory = directory;
var repository = new RepositoryBuilder()
.setGitDir(directory.resolve(".git").toFile())
.build();
this.git = new Git(repository);
}
@Override
public List<Change> getChanges(Path path) throws VersionControlException {
path = path.normalize();
if (path.startsWith(directory)) {
path = directory.relativize(path);
}
var changes = new ArrayList<Change>();
try {
for (var commit : git.log().addPath(path.toString()).call()) {
var author = commit.getAuthorIdent().getName();
var time = Instant.ofEpochSecond(commit.getCommitTime()).atZone(ZoneOffset.UTC);
changes.add(new Change(author, time));
}
} catch (GitAPIException e) {
throw new VersionControlException(e);
}
return changes;
}
}

View file

@ -0,0 +1,15 @@
/*
* 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.vc;
import java.nio.file.Path;
import java.util.List;
public interface VersionControl {
List<Change> getChanges(Path path) throws VersionControlException;
}

View file

@ -0,0 +1,15 @@
/*
* 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.vc;
// TODO runtime exception really?
public class VersionControlException extends RuntimeException {
public VersionControlException(Throwable cause) {
// TODO maybe I need to raise message here too
super(cause);
}
}