Read @webhouse/cms content from a Spring Boot application. Reader class, controllers, Thymeleaf templates.
Setup
Place your Spring Boot project and @webhouse/cms content side by side:
my-project/
cms.config.ts # Schema (used by CMS admin)
content/ # JSON documents (read by Spring Boot)
public/uploads/ # Media files
pom.xml
src/main/java/...
src/main/resources/templates/Reader class
Create src/main/java/app/webhouse/cmsreader/WebhouseReader.java:
package app.webhouse.cmsreader;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Stream;
public final class WebhouseReader {
private static final Pattern SAFE_NAME = Pattern.compile("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$");
private final Path contentDir;
private final ObjectMapper mapper = new ObjectMapper();
public WebhouseReader(String contentDir) {
this.contentDir = Path.of(contentDir).toAbsolutePath().normalize();
}
public List<WebhouseDocument> collection(String collection, String locale) {
validateName(collection);
Path dir = contentDir.resolve(collection);
if (!Files.isDirectory(dir)) return List.of();
List<WebhouseDocument> docs = new ArrayList<>();
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(p -> p.getFileName().toString().endsWith(".json")).forEach(p -> {
try {
WebhouseDocument doc = mapper.readValue(p.toFile(), WebhouseDocument.class);
if (!"published".equals(doc.status)) return;
if (locale != null && !locale.equals(doc.locale)) return;
docs.add(doc);
} catch (IOException ignored) { /* skip malformed */ }
});
} catch (IOException e) {
return List.of();
}
docs.sort(Comparator.comparing(
(WebhouseDocument d) -> d.getStringOr("date", ""),
Comparator.reverseOrder()
));
return docs;
}
public Optional<WebhouseDocument> document(String collection, String slug) {
validateName(collection);
validateName(slug);
Path file = contentDir.resolve(collection).resolve(slug + ".json");
if (!file.startsWith(contentDir) || !Files.isRegularFile(file)) {
return Optional.empty();
}
try {
WebhouseDocument doc = mapper.readValue(file.toFile(), WebhouseDocument.class);
return "published".equals(doc.status) ? Optional.of(doc) : Optional.empty();
} catch (IOException e) {
return Optional.empty();
}
}
public Optional<WebhouseDocument> findTranslation(WebhouseDocument doc, String collection) {
if (doc.translationGroup == null) return Optional.empty();
return collection(collection, null).stream()
.filter(o -> doc.translationGroup.equals(o.translationGroup))
.filter(o -> doc.locale == null || !doc.locale.equals(o.locale))
.findFirst();
}
private static void validateName(String name) {
if (name == null || !SAFE_NAME.matcher(name).matches()) {
throw new IllegalArgumentException("Invalid name: " + name);
}
}
}And the document type:
package app.webhouse.cmsreader;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true)
public class WebhouseDocument {
public String id;
public String slug;
public String status;
public String locale;
public String translationGroup;
public Map<String, Object> data;
public String getString(String key) {
if (data == null) return null;
Object v = data.get(key);
return v instanceof String ? (String) v : null;
}
public String getStringOr(String key, String fallback) {
String v = getString(key);
return v != null ? v : fallback;
}
}Spring configuration
Register the reader as a bean in your Application.java:
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public WebhouseReader webhouseReader() {
return new WebhouseReader("content");
}
}Controller
@Controller
public class BlogController {
private final WebhouseReader cms;
public BlogController(WebhouseReader cms) {
this.cms = cms;
}
@GetMapping("/")
public String home(Model model) {
model.addAttribute("posts", cms.collection("posts", "en"));
return "home";
}
@GetMapping("/blog/{slug}")
public String post(@PathVariable String slug, Model model) {
WebhouseDocument post = cms.document("posts", slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("post", post);
return "post";
}
}Thymeleaf template
<!-- src/main/resources/templates/post.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="${post.getString('title')}">Post</title>
</head>
<body>
<article>
<h1 th:text="${post.getString('title')}">Title</h1>
<time th:text="${post.getStringOr('date', '')}">date</time>
<div th:utext="${contentHtml}">content</div>
</article>
</body>
</html>Markdown rendering
Use commonmark-java for richtext fields. Add to pom.xml:
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.24.0</version>
</dependency>Then create a service:
@Service
public class MarkdownService {
private final Parser parser = Parser.builder().build();
private final HtmlRenderer renderer = HtmlRenderer.builder().build();
public String render(String markdown) {
return renderer.render(parser.parse(markdown != null ? markdown : ""));
}
}Inject it into your controller and pass markdownService.render(post.getString("content")) as contentHtml.
Serving uploaded media
In application.properties:
spring.web.resources.static-locations=classpath:/static/,file:public/Now /uploads/my-image.jpg is served from public/uploads/my-image.jpg.
i18n: reading both locales
// All Danish posts
List<WebhouseDocument> daPosts = cms.collection("posts", "da");
// Find the translation of a specific post
WebhouseDocument post = cms.document("posts", "hello-world").orElseThrow();
WebhouseDocument translation = cms.findTranslation(post, "posts").orElse(null);Caching
Use Spring's @Cacheable:
@Service
public class CachedWebhouse {
private final WebhouseReader reader;
@Cacheable("posts")
public List<WebhouseDocument> posts(String locale) {
return reader.collection("posts", locale);
}
}Invalidate via CMS admin webhook when content changes.
Production deployment
- Fly.io / Render / Heroku — Spring Boot apps work out of the box
- Bare-metal Tomcat — change packaging to
warin pom.xml - Docker — multi-stage build with
eclipse-temurin:21-jre-alpine - AWS Beanstalk / Azure App Service — native Java support
Future: Maven Central package
In F125 Phase 2, this reader will be published to Maven Central as app.webhouse:cms-reader so you can:
<dependency>
<groupId>app.webhouse</groupId>
<artifactId>cms-reader</artifactId>
<version>0.1.0</version>
</dependency>For now, copy the WebhouseReader.java and WebhouseDocument.java files into your project (~150 lines total).
Next steps
- See the Java example
- Learn about Framework-Agnostic Architecture