webhouse.appwebhouse.appdocs

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:

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:

java
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:

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

java
@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

html
<!-- 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:

xml
<dependency>
    <groupId>org.commonmark</groupId>
    <artifactId>commonmark</artifactId>
    <version>0.24.0</version>
</dependency>

Then create a service:

java
@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:

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

java
// 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:

java
@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 war in 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:

xml
<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

Tags:FrameworksFilesystem Adapter
Previous
Schema Export — webhouse-schema.json
Next
Consume from Laravel (PHP)
JSON API →Edit on GitHub →