Compare commits

...

24 commits

Author SHA1 Message Date
121a2c6915
Support folders
Some checks are pending
/ build (push) Waiting to run
Closes #3

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-27 14:34:36 +01:00
0aadf69a42
Use slf4j
All checks were successful
/ build (push) Successful in 31s
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-25 09:42:30 +01:00
159519c94d
Configurable compressors
All checks were successful
/ build (push) Successful in 36s
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-24 14:43:52 +01:00
42a73ece51
New config scheme in example_workdir
All checks were successful
/ build (push) Successful in 32s
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-22 16:40:25 +01:00
1557a47987
Rename site-config to site.yml
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-22 16:35:30 +01:00
a7ab3b400c
feat: Render options
All checks were successful
/ build (push) Successful in 35s
for now only includes compress true or false

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-22 16:31:11 +01:00
950645dcef
feat: Compression
All checks were successful
/ build (push) Successful in 37s
and refactoring

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-20 15:29:47 +01:00
3d4597b198
refactor: Use SimpleFileServer
All checks were successful
/ build (push) Successful in 27s
Instead of own HttpHandler

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-13 11:12:22 +01:00
567bbd8c37
docs: Update and improve README
All checks were successful
/ build (push) Successful in 37s
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-12 16:23:26 +01:00
0b91f2d7a1
docs: Update example workdir with YAML
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-12 16:23:04 +01:00
d155079514
feat!: Use YAML instead of JSON
site-config.json is now site-config.yml.
That was the only usage of JSON, so not much changes.

Dep removed: org.json:json
Added: org.snakeyaml:snakeyaml-engine

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-12 16:17:43 +01:00
721d8d2768
ci: Add artifact README
All checks were successful
/ build (push) Successful in 23s
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-10 17:22:44 +01:00
734a1ef497
ci: Fix actions
All checks were successful
/ build (push) Successful in 24s
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-10 16:54:42 +01:00
f265376ef8
chore: Update deps
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-10 16:54:17 +01:00
930a22d55a
docs: Add more to README
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-10 16:26:08 +01:00
37535e2c35
refactor: clean up unused imports
Some checks failed
/ build (push) Failing after 8s
- Removed unused Pebble extension-related imports.
- Improved readability by eliminating unnecessary dependencies.

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 16:01:06 +01:00
e89252bb71
refactor: use StringBuilder in RSS generation
- Replaced string concatenation with StringBuilder for efficiency.
- Moved DateTimeFormatter to a static final field to improve reusability and readability.
- Improved structure and readability of RSS generation logic.

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 16:00:44 +01:00
0a6d8c09c5
refactor: simplify while loop logic
- Replace `removeFirst()` check with `isEmpty()` for cleaner loop condition.
- Improve code readability and maintainability.

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 15:55:40 +01:00
8e4fae069d
refactor: extract template functions into TemplateExtension
- Introduced `TemplateExtension` to encapsulate template functions.
- Moved `static` and `asset` functions from `BlogBuilder` to `TemplateExtension`.
- Adjusted package structure for `TemplateRenderer` and new `TemplateExtension`.

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 15:54:22 +01:00
b339d6d239
docs: add Javadoc for core classes
- Added Javadoc comments to `Site`, `Server`, `Feed`, `BlogBuilder`, `TemplateRenderer`, and `Post` classes.
- Documented constructors, methods, and class-level descriptions.
- Enhanced clarity around parameters, return values, and exceptions.

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 15:50:32 +01:00
d5f6eaf487
docs: update README for clarity
- Updated download instructions with new link and asset details
- Updated example command to match new JAR naming convention
- Added new "API" section for integration guidance
- Linked Maven dependency and example `Main.java`

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 13:59:52 +01:00
3c3c8e0c5c
remove release.properties
Some checks are pending
/ build (push) Waiting to run
Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 13:59:36 +01:00
b2725d9960
chore: mark commons-cli as optional
- Added `<optional>true</optional>` to the commons-cli dependency in `pom.xml`
- Ensures the dependency is not transitively included

Signed-off-by: Minecon724 <git@m724.eu>
2025-02-08 13:54:40 +01:00
9979845efa
[maven-release-plugin] prepare for next development iteration
Some checks are pending
/ build (push) Waiting to run
2025-02-08 13:51:09 +01:00
21 changed files with 585 additions and 268 deletions

View file

@ -5,7 +5,7 @@ jobs:
container: eclipse-temurin:21-alpine
steps:
- name: Install JDK and other deps
run: apk add maven git nodejs curl
run: apk upgrade && apk add maven git nodejs curl
- name: Checkout
uses: https://github.com/actions/checkout@v4
@ -13,9 +13,13 @@ jobs:
- name: Build
run: mvn package
- name: Add build data
run: "mkdir meta && git log -1 > meta/commit.txt && date > meta/build-date.txt"
- name: Upload artifacts
uses: https://github.com/actions/upload-artifact@v3
with:
name: built-jar
path: target/blog-software-java-*.jar
path: |
meta
target/blog-*.jar
if-no-files-found: error

View file

@ -2,14 +2,46 @@ blog-software(config, template, content) = blog website
## Usage
1. [Download the program](https://git.m724.eu/Minecon724/blog-software-java/actions/runs/latest/artifacts/built-jar)
2. Get a working directory. [Example](https://git.m724.eu/Minecon724/blog-software-java/src/branch/master/example_workdir) \
Don't forget to `git init`!
3. Run the program:
1. [Download the program from here](/Minecon724/blog-software-java/releases)
2. Run the program:
```shell
java -jar blog-software-java-1.0-SNAPSHOT.jar
java -jar blog-0.0.1-shaded.jar -s example_workdir
```
For tips on how to create your own project (workdir), see [Project format](#Project format) below.
## Important caveats
Generated site must be the root of a subdomain, like `https://example.com/`. \
You can't put it in a directory, like `https://example.com/blog/`
## API
There's no "API," but it's possible to integrate this into your Java project.
See [Main.java](/Minecon724/blog-software-java/src/branch/master/src/main/java/eu/m724/blog/Main.java) for an example. \
If you need a Maven dependency, [see here](/Minecon724/-/packages/maven/eu.m724-blog)
## Project format
There's an ["Example workdir"](/Minecon724/blog-software-java/src/branch/master/example_workdir) which you can take inspiration from.
Basically:
- `assets/` - contains static assets
- `posts/` - contains posts. Post format:
- Header / metadata:
- `title A title` - post title
- `summary This is a post with a title` - post summary
- `live` - is the post live (not draft), doesn't need an argument
- Custom properties, which are Strings
- ` ` - Empty line separates header from content
- Post content in HTML. Generally not sanitized, but depends on template.
- `template/` - contains the template, see [Template format](#Template format) below
- `site-config.yml` - the site configuration
- `name: my blog` property - site name
- `baseUrl: https://example.com` property - URL of the site. It must be a root URL - folders are currently not supported. This is used only for the RSS feed.
- Custom properties, which can be anything as they are Objects
## Template format
https://pebbletemplates.io is used
- `static/` - contains static assets
- `article_template.html` - post template
- `index_template.html` - index.html template

View file

@ -0,0 +1,5 @@
# Render options here
compress:
- gz
- zstd

View file

@ -1,9 +0,0 @@
{
"name": "my blog",
"baseUrl": "https://example.com",
"coolProperty": 1231,
"coolerProperty": {
"isMap": "yes"
}
}

10
example_workdir/site.yml Normal file
View file

@ -0,0 +1,10 @@
name: my blog
baseUrl: https://example.com/blog
coolProperty: 1231
coolerProperty:
isMap: true
aList:
- a value
- another value
- check out site-config.yml!

View file

@ -20,7 +20,12 @@
<h1>{{ site.name }} - {{ site.custom.coolerProperty.get('isMap') }}</h1>
<article>
<header>
<h2><a href="">{{ article.title }}</a></h2>
<h2>
<a href="">{{ article.title }}</a>
{% if article.draft %}
<strong>DRAFT</strong>
{% endif %}
</h2>
<h4>{{ article.summary }}</h4>
<p>{{ article.createdAt | date("dd.MM.yyyy") }} by {{ article.createdBy }}</p>

View file

@ -19,5 +19,11 @@
</article>
</a>
{% endfor %}
<ul>
{% for e in site.custom.coolerProperty.aList %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</body>
</html>

31
pom.xml
View file

@ -4,7 +4,7 @@
<groupId>eu.m724</groupId>
<artifactId>blog</artifactId>
<version>0.0.1</version>
<version>0.0.2-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
@ -22,7 +22,8 @@
<dependency>
<groupId>commons-cli</groupId>
<artifactId>commons-cli</artifactId>
<version>1.8.0</version>
<version>1.9.0</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.eclipse.jgit</groupId>
@ -32,19 +33,31 @@
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId>
<version>3.2.2</version>
<version>3.2.3</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20250107</version>
<groupId>org.snakeyaml</groupId>
<artifactId>snakeyaml-engine</artifactId>
<version>2.9</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<groupId>commons-io</groupId> <!-- https://stackoverflow.com/questions/32184114 -->
<artifactId>commons-io</artifactId>
<version>2.16.1</version>
<version>2.18.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.27.1</version>
</dependency>
<dependency>
<groupId>com.github.luben</groupId>
<artifactId>zstd-jni</artifactId>
<version>1.5.6-10</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
@ -149,6 +162,6 @@
<scm>
<developerConnection>scm:git:git@git.m724.eu:Minecon724/blog-software-java.git</developerConnection>
<tag>blog-0.0.1</tag>
<tag>HEAD</tag>
</scm>
</project>

View file

@ -1,21 +0,0 @@
#release configuration
#Sat Feb 08 12:48:09 CET 2025
completedPhase=check-poms
exec.pomFileName=pom.xml
exec.snapshotReleasePluginAllowed=false
pinExternals=false
preparationGoals=clean verify
project.scm.eu.m724\:blog-software-java.developerConnection=scm\:git\:git@git.m724.eu\:Minecon724/blog-software-java.git
project.scm.eu.m724\:blog-software-java.tag=HEAD
projectVersionPolicyConfig=<projectVersionPolicyConfig>${projectVersionPolicyConfig}</projectVersionPolicyConfig>\n
projectVersionPolicyId=default
pushChanges=true
releaseStrategyId=default
remoteTagging=true
scm.branchCommitComment=@{prefix} prepare branch @{releaseLabel}
scm.commentPrefix=[maven-release-plugin]
scm.developmentCommitComment=@{prefix} prepare for next development iteration
scm.releaseCommitComment=@{prefix} prepare release @{releaseLabel}
scm.rollbackCommitComment=@{prefix} rollback the release of @{releaseLabel}
scm.tagNameFormat=@{project.artifactId}-@{project.version}
scm.url=scm\:git\:git@git.m724.eu\:Minecon724/blog-software-java.git

View file

@ -1,11 +1,17 @@
package eu.m724.blog;
import eu.m724.blog.compress.FileCompressor;
import eu.m724.blog.data.Feed;
import eu.m724.blog.data.Post;
import eu.m724.blog.data.RenderOptions;
import eu.m724.blog.data.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;
@ -13,19 +19,34 @@ 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.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;
@ -34,6 +55,14 @@ public class BlogBuilder {
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())
@ -45,16 +74,34 @@ public class BlogBuilder {
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;
@ -72,18 +119,55 @@ public class BlogBuilder {
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-config.json"));
this.site = Site.fromConfig(workingDirectory.resolve("site.yml"));
if (renderOptions == null)
this.renderOptions = RenderOptions.fromConfig(workingDirectory.resolve("render.yml"));
if (template == null)
this.template = new TemplateRenderer(templateDirectory);
this.template = new TemplateRenderer(site, templateDirectory);
copyIfExists(workingDirectory.resolve("assets"), outputDirectory.resolve("assets"));
copyIfExists(templateDirectory.resolve("static"), outputDirectory.resolve("static"));
LOGGER.debug("Copying assets...");
copyTree(workingDirectory.resolve("assets"), outputDirectory.resolve("assets"));
copyTree(templateDirectory.resolve("static"), outputDirectory.resolve("static"));
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)) {
@ -92,7 +176,7 @@ public class BlogBuilder {
continue; // directory is created below
if (!path.toString().endsWith(".html")) {
System.out.println("Post " + path.getFileName() + ": unsupported file type");
LOGGER.warn("Post {}: unsupported file type", path.getFileName());
continue;
}
@ -100,11 +184,11 @@ public class BlogBuilder {
var post = Post.fromFile(git, path);
if (post.draft() && !renderDrafts) {
System.out.println("Post " + path.getFileName() + ": draft, ignoring");
LOGGER.info("Post {}: draft, ignoring", path.getFileName());
continue;
}
var render = template.renderPost(site, post);
var render = template.renderPost(post);
var outFile = outputDirectory.resolve("post").resolve(path);
try {
@ -116,20 +200,34 @@ public class BlogBuilder {
}
}
posts.sort(Comparator.comparing(Post::createdAt).reversed());
Files.writeString(outputDirectory.resolve("index.html"), template.renderIndex(site, posts));
return posts;
}
Files.writeString(outputDirectory.resolve("posts.rss"), Feed.generateRss(site, posts));
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 boolean copyTree(Path srcDir, Path destDir) throws IOException {
if (!Files.isDirectory(srcDir)) {
return false;
}
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);
@ -146,17 +244,5 @@ public class BlogBuilder {
}
}
}
return true;
}
private void copyIfExists(Path srcDir, Path destDir) throws IOException {
System.out.print(srcDir);
if (copyTree(srcDir, destDir)) {
System.out.println(" copied");
} else {
System.out.println(" doesn't exist, not copying");
}
}
}

View file

@ -1,14 +1,16 @@
package eu.m724.blog;
import org.apache.commons.cli.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
public class Main {
public static void main(String[] args) throws IOException {
System.out.println("Hello world!");
private static final Logger LOGGER = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) throws IOException {
var commandLine = getCommandLine(args);
if (commandLine == null)
@ -36,21 +38,15 @@ public class Main {
.renderDrafts(renderDrafts);
builder.mkdirs(force);
LOGGER.info("Building...");
builder.build();
var end = System.nanoTime();
System.out.printf("Exported to %s (%.2f ms)\n", outputDirectory, (end - start) / 1000000.0);
// BAD
LOGGER.info("Exported to {} in {} ms", outputDirectory.toAbsolutePath(), "%.2f".formatted((end - start) / 1000000.0));
/* Server process */
if (startServer) {
var server = new Server(outputDirectory);
server.start();
if (openBrowser) {
server.openBrowser();
}
}
builder.startServer(openBrowser);
}
private static CommandLine getCommandLine(String[] args) {

View file

@ -1,71 +1,81 @@
package eu.m724.blog;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.SimpleFileServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class Server implements HttpHandler {
private final Path webroot;
/**
* The {@code Server} class represents a basic HTTP server designed to serve files
* from a specified webroot directory.<br>
* {@link SimpleFileServer} is used.
*/
public class Server {
private static final Logger LOGGER = LoggerFactory.getLogger(Server.class);
private final Path sitePath;
private final String contextPath;
private InetSocketAddress listenAddress;
public Server(Path webroot, InetSocketAddress listenAddress) {
this.webroot = webroot;
/**
* Constructs a {@link Server} instance with the specified webroot directory
* and the specified listening address.
*
* @param sitePath the directory from which files will be served
* @param contextPath TODO explain
* @param listenAddress the address and port the server will listen on
*/
public Server(Path sitePath, String contextPath, InetSocketAddress listenAddress) {
this.sitePath = sitePath;
this.contextPath = contextPath;
this.listenAddress = listenAddress;
}
public Server(Path webroot) {
this(webroot, new InetSocketAddress("localhost", 0));
/**
* Constructs a {@link Server} instance with the specified webroot directory.
* The server will bind to the localhost address with a dynamically chosen port.
*
* @param sitePath the directory from which files will be served
* @param contextPath TODO explain
*/
public Server(Path sitePath, String contextPath) {
this(sitePath, contextPath, new InetSocketAddress("localhost", 0));
}
/**
* Starts an HTTP server on the specified listen address.
*
* @throws IOException if an I/O error occurs during server initialization
*/
public void start() throws IOException {
System.out.println(contextPath);
var server = HttpServer.create(listenAddress, 0);
server.createContext("/", this);
server.createContext(contextPath, SimpleFileServer.createFileHandler(sitePath.toAbsolutePath()));
server.start();
System.out.println("Server started on http:/" + server.getAddress());
LOGGER.info("Server started on http:/{}", server.getAddress());
this.listenAddress = server.getAddress();
}
/**
* Attempts to open the default web browser and navigate to the server's URL.
*/
public void openBrowser() {
try {
var process = Runtime.getRuntime().exec(new String[] { "xdg-open", "http://" + listenAddress.getHostString() + ":" + listenAddress.getPort() });
var process = Runtime.getRuntime().exec(new String[] { "xdg-open", "http://" + listenAddress.getHostString() + ":" + listenAddress.getPort() + contextPath });
var code = process.waitFor();
if (code != 0) {
throw new Exception("Exit code " + code);
}
System.out.println("Opened browser");
LOGGER.info("Opened browser"); // TODO make this debug?
} catch (Exception e) {
System.out.println("Failed to open browser: " + e);
LOGGER.error("Failed to open browser: {}", String.valueOf(e));
}
}
@Override
public void handle(HttpExchange exchange) throws IOException {
var path = webroot.resolve(exchange.getRequestURI().getPath().substring(1));
if (Files.isDirectory(path)) {
path = path.resolve("index.html");
}
var code = 404;
var content = "Not found".getBytes(StandardCharsets.UTF_8);
if (Files.isRegularFile(path)) {
code = 200;
content = Files.readAllBytes(path);
}
exchange.sendResponseHeaders(code, content.length);
var body = exchange.getResponseBody();
body.write(content);
body.close();
}
}

View file

@ -1,87 +0,0 @@
package eu.m724.blog;
import eu.m724.blog.data.Post;
import eu.m724.blog.data.Site;
import io.pebbletemplates.pebble.PebbleEngine;
import io.pebbletemplates.pebble.extension.AbstractExtension;
import io.pebbletemplates.pebble.extension.Function;
import io.pebbletemplates.pebble.loader.FileLoader;
import io.pebbletemplates.pebble.template.EvaluationContext;
import io.pebbletemplates.pebble.template.PebbleTemplate;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class TemplateRenderer {
private final PebbleTemplate indexTemplate, articleTemplate;
public TemplateRenderer(Path templateDirectory) {
var loader = new FileLoader();
loader.setPrefix(templateDirectory.toString());
loader.setSuffix(".html");
var pebbleEngine = new PebbleEngine.Builder()
.loader(loader)
.extension(new AbstractExtension() {
@Override
public Map<String, Function> getFunctions() {
return Map.of(
"static", new Function() {
@Override
public List<String> getArgumentNames() {
return List.of("path");
}
@Override
public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
return "/static/" + args.get("path"); // TODO for more advanced stuff
}
},
"asset", new Function() {
@Override
public List<String> getArgumentNames() {
return List.of("path");
}
@Override
public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
return "/assets/" + args.get("path"); // TODO for more advanced stuff
}
}
);
}
})
.build();
this.indexTemplate = pebbleEngine.getTemplate("index_template");
this.articleTemplate = pebbleEngine.getTemplate("article_template");
}
public String renderIndex(Site site, ArrayList<Post> posts) throws IOException {
Map<String, Object> context = Map.of(
"site", site,
"articles", posts
);
var writer = new StringWriter();
indexTemplate.evaluate(writer, context);
return writer.toString();
}
public String renderPost(Site site, Post post) throws IOException {
Map<String, Object> context = Map.of(
"site", site,
"article", post
);
var writer = new StringWriter();
articleTemplate.evaluate(writer, context);
return writer.toString();
}
}

View file

@ -0,0 +1,36 @@
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.nio.file.*;
public class FileCompressor {
private final String algorithm;
public FileCompressor(String algorithm) {
this.algorithm = algorithm;
}
public void compress(Path source) throws IOException, CompressorException {
var destination = source.resolveSibling(source.getFileName() + "." + algorithm);
compress(source, destination);
}
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;
}
}

View file

@ -4,17 +4,32 @@ import java.time.format.DateTimeFormatter;
import java.util.List;
public class Feed {
public static String generateRss(Site site, List<Post> posts) {
var content = "<?xml version=\"1.0\" encoding=\"UTF-8\" ?><rss version=\"2.0\">";
content += "<channel><title>%s</title><link>%s</link>".formatted(site.name(), site.baseUrl());
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz");
/**
* Generates an RSS feed XML string for a given website and its list of blog posts.
*
* @param site the {@code Site} object representing the website for which the RSS feed is generated
* @param posts the list of {@code Post} objects representing the blog posts to include in the RSS feed
* @return a {@code String} containing the formatted RSS feed in XML
*/
public static String generateRss(Site site, List<Post> posts) {
var content = new StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>");
content.append("<rss version=\"2.0\"><channel>");
content.append("<title>%s</title>".formatted(site.name()));
content.append("<link>%s</link>".formatted(site.baseUrl()));
var formatter = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz");
for (var post : posts) {
content += "<item><title>%s</title><link>%s/post/%s.html</link><description>%s</description><pubDate>%s</pubDate></item>".formatted(post.title(), site.baseUrl(), post.slug(), post.summary(), post.createdAt().format(formatter));
content.append("<item>");
content.append("<title>%s</title>".formatted(post.title()));
content.append("<link>%s/post/%s.html</link>".formatted(site.baseUrl(), post.slug()));
content.append("<description>%s</description>".formatted(post.summary()));
content.append("<pubDate>%s</pubDate>".formatted(post.createdAt().format(formatter)));
content.append("</item>");
}
content += "</channel></rss>";
content.append("</channel></rss>");
return content;
return content.toString();
}
}

View file

@ -2,6 +2,8 @@ package eu.m724.blog.data;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.file.Files;
@ -10,6 +12,21 @@ import java.time.*;
import java.util.HashMap;
import java.util.Map;
/**
* The {@code Post} class represents a blog post with various attributes including metadata and content.
*
* @param slug A unique identifier for the post derived from the file name.
* @param title The title of the post.
* @param summary A brief summary of the post content.
* @param draft Indicates whether the post is marked as a draft or published.
* @param revisions The number of revisions the post has undergone in version control.
* @param createdBy The name of the author who created the post.
* @param createdAt The timestamp of when the post was first created.
* @param modifiedBy The name of the author who last modified the post.
* @param modifiedAt The timestamp of the last modification to the post.
* @param custom A map of custom properties or metadata associated with the post.
* @param rawContent The raw content of the post, which <em>currently</em> is usually HTML.
*/
public record Post(
String slug,
String title,
@ -25,11 +42,19 @@ public record Post(
Map<String, String> custom,
String rawContent
) {
// this is because we'll be not only supporting html
public String htmlContent() {
return rawContent;
}
private static final Logger LOGGER = LoggerFactory.getLogger(Post.class);
/**
* Creates a {@link Post} instance by reading and parsing the content of a post file.
* <p>
* 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 path the relative path to the file within the "posts" directory
* @return a {@link Post} object populated with data extracted from the specified file
* @throws IOException if an error occurs during file reading
*/
public static Post fromFile(Git git, Path path) throws IOException {
/* read properties before filtering */
@ -39,8 +64,8 @@ public record Post(
var properties = new HashMap<String, String>();
String line;
while ((line = lines.removeFirst()) != null) {
while (!lines.isEmpty()) {
var line = lines.removeFirst();
var parts = line.split(" ");
var key = parts[0].toLowerCase();
@ -50,7 +75,7 @@ public record Post(
break;
if (properties.putIfAbsent(key, data) != null)
System.out.printf("Post %s: Duplicate property \"%s\". Only the first one will be used.\n", slug, key);
LOGGER.warn("[Post {}] Ignoring duplicate property: {}", slug, key);
}
var content = String.join("\n", lines).strip();
@ -101,9 +126,18 @@ public record Post(
}
} catch (GitAPIException e) {
draft = true;
System.out.printf("%s: Git exception, making draft: %s\n", slug, e.getMessage());
LOGGER.warn("[Post {}] Draft because of a Git exception: {}\n", slug, e.getMessage());
}
return new Post(slug, title, summary, draft, revisions, createdBy, createdAt, modifiedBy, modifiedAt, custom, content);
}
/**
* Retrieves the raw HTML content associated with the post.
*
* @return the raw HTML content as a string
*/
public String htmlContent() {
return rawContent;
}
}

View file

@ -0,0 +1,50 @@
package eu.m724.blog.data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.ArrayList;
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);
/**
* 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
* @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));
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);
}
}
return new RenderOptions(
compress
);
}
}

View file

@ -1,6 +1,7 @@
package eu.m724.blog.data;
import org.json.JSONObject;
import org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
import java.io.IOException;
import java.nio.file.Files;
@ -8,22 +9,40 @@ import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
/**
* The {@code Site} class represents a website and its configuration.
*
* @param name the name of the site
* @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 custom a map of additional custom properties
*/
public record Site(
String name,
String baseUrl,
String directory,
Map<String, Object> custom
) {
/**
* 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
* @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 content = Files.readString(path);
var json = new JSONObject(content);
var load = new Load(LoadSettings.builder().build());
var yaml = (Map<String, Object>) load.loadFromInputStream(Files.newInputStream(path));
String name = null;
String baseUrl = null;
var custom = new HashMap<String, Object>();
for (var key : json.keySet()) {
var value = json.get(key);
for (var key : yaml.keySet()) {
var value = yaml.get(key);
switch (key) {
case "name":
@ -37,40 +56,14 @@ public record Site(
}
}
String directory = null;
if (baseUrl != null) {
var temp = baseUrl.substring(baseUrl.indexOf(':') + 3);
directory = temp.substring(temp.indexOf('/'));
}
return new Site(
name, baseUrl, custom
name, baseUrl, directory, custom
);
}
/* remained from gson
private static Map<String, Object> deep(JsonObject jsonObject) {
return jsonObject.entrySet().stream()
.map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), eToO(e.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
private static Object eToO(JsonElement element) {
if (element.isJsonArray()) {
return element.getAsJsonArray().asList().stream().map(Site::eToO).toList();
} else if (element.isJsonObject()) {
return deep(element.getAsJsonObject());
} else if (element.isJsonPrimitive()) {
try {
return element.getAsBoolean();
} catch (IllegalStateException e) { }
try {
return element.getAsLong();
} catch (NumberFormatException e) { }
try {
return element.getAsDouble();
} catch (NumberFormatException e) { }
// TODO
}
return null;
}*/
}

View file

@ -0,0 +1,47 @@
package eu.m724.blog.template;
import eu.m724.blog.data.Site;
import io.pebbletemplates.pebble.extension.AbstractExtension;
import io.pebbletemplates.pebble.extension.Function;
import io.pebbletemplates.pebble.template.EvaluationContext;
import io.pebbletemplates.pebble.template.PebbleTemplate;
import java.util.List;
import java.util.Map;
public class TemplateExtension extends AbstractExtension {
private final Site site;
public TemplateExtension(Site site) {
this.site = site;
}
@Override
public Map<String, Function> getFunctions() {
return Map.of(
"static", new Function() {
@Override
public List<String> getArgumentNames() {
return List.of("path");
}
@Override
public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
return site.directory() + "/static/" + args.get("path");
}
},
"asset", new Function() {
@Override
public List<String> getArgumentNames() {
return List.of("path");
}
@Override
public Object execute(Map<String, Object> args, PebbleTemplate self, EvaluationContext context, int lineNumber) {
return site.directory() + "/assets/" + args.get("path");
}
}
// TODO make url_for that supports relative and absolute paths
);
}
}

View file

@ -0,0 +1,81 @@
package eu.m724.blog.template;
import eu.m724.blog.data.Post;
import eu.m724.blog.data.Site;
import io.pebbletemplates.pebble.PebbleEngine;
import io.pebbletemplates.pebble.loader.FileLoader;
import io.pebbletemplates.pebble.template.PebbleTemplate;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/**
* The {@code TemplateRenderer} class is responsible for rendering dynamic HTML templates
* using the Pebble templating engine.
*/
public class TemplateRenderer {
private final Site site;
private final PebbleTemplate indexTemplate, articleTemplate;
/**
* 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
*/
public TemplateRenderer(Site site, Path templateDirectory) {
var loader = new FileLoader();
loader.setPrefix(templateDirectory.toString());
loader.setSuffix(".html");
var pebbleEngine = new PebbleEngine.Builder()
.loader(loader)
.extension(new TemplateExtension(site))
.build();
this.site = site;
this.indexTemplate = pebbleEngine.getTemplate("index_template");
this.articleTemplate = pebbleEngine.getTemplate("article_template");
}
/**
* Renders the index page using this template.
*
* @param posts the {@link Post}s to be included in the index page
* @return the rendered index HTML page as a string
* @throws IOException if an error occurs during the template evaluation
*/
public String renderIndex(List<Post> posts) throws IOException {
Map<String, Object> context = Map.of(
"site", site,
"articles", posts
);
var writer = new StringWriter();
indexTemplate.evaluate(writer, context);
return writer.toString();
}
/**
* Renders the content of a post using this template.
*
* @param post the {@link Post} to be rendered
* @return the rendered post HTML page as a string
* @throws IOException if an error occurs during template evaluation
*/
public String renderPost(Post post) throws IOException {
Map<String, Object> context = Map.of(
"site", site,
"article", post
);
var writer = new StringWriter();
articleTemplate.evaluate(writer, context);
return writer.toString();
}
}

View file

@ -0,0 +1,11 @@
org.slf4j.simpleLogger.defaultLogLevel=info
# TODO maybe?
#org.slf4j.simpleLogger.showDateTime=true
#org.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss.SSS
org.slf4j.simpleLogger.showThreadName=false
org.slf4j.simpleLogger.showShortLogName=true
org.slf4j.simpleLogger.levelInBrackets=true
org.slf4j.simpleLogger.logFile=System.out