ModulithDocusaurusRenderer.java
package li.selman.modulithdocsmarkdown;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import org.springframework.modulith.core.ApplicationModule;
import org.springframework.modulith.core.ApplicationModuleDependency;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.DependencyType;
/**
* Renders an {@link ApplicationModules} instance as Docusaurus-flavoured Markdown with Mermaid diagrams, carrying the
* same semantic information as Spring Modulith's built-in {@code Documenter} (AsciiDoc + PlantUML). See
* {@link ModuleFacts} for the canonical fact set every module page emits, and {@link ModulithMermaid} for how
* diagrams are derived from the same model {@code Documenter} builds.
* <p>
* Two configuration seams exist by design, both defaulted to the simplest choice:
* <ul>
* <li>{@link MarkupEmitter} — plain {@code .md} by default; swap in an MDX-flavoured emitter without touching this
* class.</li>
* <li>{@link CanvasStyle} — headed sections with bullet lists by default; {@link CanvasStyle#HTML_TABLE} renders the
* canvas as a single HTML {@code <table>}, a closer 1:1 mirror of the AsciiDoc canvas, at the cost of Markdown tables
* not supporting block content natively.</li>
* </ul>
*/
public final class ModulithDocusaurusRenderer {
public enum CanvasStyle {
SECTIONS,
HTML_TABLE
}
private record CanvasRow(String heading, String content) {}
private final ApplicationModules modules;
private final ModulithMermaid mermaid;
private final MarkupEmitter emitter;
private final CanvasStyle canvasStyle;
public ModulithDocusaurusRenderer(ApplicationModules modules) {
this(modules, MarkupEmitter.markdown(), CanvasStyle.SECTIONS);
}
private ModulithDocusaurusRenderer(ApplicationModules modules, MarkupEmitter emitter, CanvasStyle canvasStyle) {
this.modules = modules;
this.mermaid = new ModulithMermaid(modules);
this.emitter = emitter;
this.canvasStyle = canvasStyle;
}
public ModulithDocusaurusRenderer withMarkupEmitter(MarkupEmitter emitter) {
return new ModulithDocusaurusRenderer(modules, emitter, canvasStyle);
}
public ModulithDocusaurusRenderer withCanvasStyle(CanvasStyle canvasStyle) {
return new ModulithDocusaurusRenderer(modules, emitter, canvasStyle);
}
/**
* The Docusaurus route slug for a module: its identifier, lowercased, with every run of non-alphanumeric
* characters collapsed to a single hyphen.
*/
public static String slug(ApplicationModule module) {
return module.getIdentifier()
.toString()
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+|-+$", "");
}
/**
* Writes one page per module plus an {@code index} page with the overview diagram, into the given directory.
*/
public ModulithDocusaurusRenderer writeTo(Path directory) throws IOException {
Files.createDirectories(directory);
var sortedModules = modules.stream()
.sorted(Comparator.comparing(ApplicationModule::getDisplayName))
.toList();
for (int i = 0; i < sortedModules.size(); i++) {
var module = sortedModules.get(i);
var page = renderModulePage(module, i + 1);
Files.writeString(directory.resolve(slug(module) + "." + emitter.fileExtension()), page);
}
Files.writeString(directory.resolve("index." + emitter.fileExtension()), renderIndexPage());
return this;
}
String renderModulePage(ApplicationModule module, int sidebarPosition) {
var facts = ModuleFacts.from(module, modules);
var displayName = module.getDisplayName();
return frontMatter(slug(module), displayName, sidebarPosition)
+ emitter.preamble()
+ "# " + displayName + System.lineSeparator() + System.lineSeparator()
+ emitter.mermaid(mermaid.moduleDiagram(module)) + System.lineSeparator()
+ renderCanvas(buildRows(module, facts));
}
String renderIndexPage() {
var systemName = modules.getSystemName().orElse("Modules");
var links = modules.stream()
.sorted(Comparator.comparing(ApplicationModule::getDisplayName))
.map(module -> "- [%s](./%s)".formatted(module.getDisplayName(), slug(module)))
.collect(Collectors.joining(System.lineSeparator()));
return frontMatter("index", systemName, 0)
+ emitter.preamble()
+ "# " + systemName + System.lineSeparator() + System.lineSeparator()
+ emitter.mermaid(mermaid.overviewDiagram()) + System.lineSeparator()
+ "## Modules" + System.lineSeparator() + System.lineSeparator()
+ links + System.lineSeparator();
}
private static String frontMatter(String id, String title, int sidebarPosition) {
return """
---
id: %s
title: %s
sidebar_label: %s
sidebar_position: %d
---
""".formatted(id, title, title, sidebarPosition);
}
private List<CanvasRow> buildRows(ApplicationModule module, ModuleFacts facts) {
return List.of(
new CanvasRow(
"Description",
PackageDescriptions.forPackage(facts.basePackage()).orElse("_None_")),
new CanvasRow("Base package", "`" + facts.basePackage() + "`"),
new CanvasRow("Spring components", bulletList(facts.springBeans())),
new CanvasRow("Bean references", beanReferences(module)),
new CanvasRow("Aggregate roots", bulletList(facts.aggregateRoots())),
new CanvasRow("Value types", bulletList(facts.valueTypes())),
new CanvasRow("Published events", bulletList(facts.publishedEvents())),
new CanvasRow("Events listened to", bulletList(facts.eventsListenedTo())),
new CanvasRow("Configuration properties", configProperties(module)));
}
/**
* The single swap point between rendering the canvas as headed sections (default) and as one HTML
* {@code <table>} — a one-line {@link #withCanvasStyle(CanvasStyle)} configuration choice, not a rewrite.
*/
private String renderCanvas(List<CanvasRow> rows) {
return switch (canvasStyle) {
case SECTIONS -> rows.stream().map(this::section).collect(Collectors.joining());
case HTML_TABLE -> htmlTable(rows);
};
}
private String section(CanvasRow row) {
return "## " + row.heading() + System.lineSeparator() + System.lineSeparator() + row.content()
+ System.lineSeparator() + System.lineSeparator();
}
private String htmlTable(List<CanvasRow> rows) {
var builder = new StringBuilder("<table>").append(System.lineSeparator());
for (var row : rows) {
builder.append(" <tr><th>")
.append(row.heading())
.append("</th><td>")
.append(System.lineSeparator())
.append(System.lineSeparator())
.append(row.content())
.append(System.lineSeparator())
.append(System.lineSeparator())
.append("</td></tr>")
.append(System.lineSeparator());
}
return builder.append("</table>").append(System.lineSeparator()).toString();
}
private String beanReferences(ApplicationModule module) {
var lines = module.getDirectDependencies(modules, DependencyType.USES_COMPONENT)
.uniqueStream(ApplicationModuleDependency::getTargetType)
.map(dependency -> "- `%s` (in [%s](./%s))"
.formatted(
dependency.getTargetType().getSimpleName(),
dependency.getTargetModule().getDisplayName(),
slug(dependency.getTargetModule())))
.collect(Collectors.joining(System.lineSeparator()));
return lines.isBlank() ? "_None_" : lines;
}
private String configProperties(ApplicationModule module) {
var lines = ConfigMetadata.propertiesFor(module).stream()
.map(ModulithDocusaurusRenderer::configPropertyLine)
.collect(Collectors.joining(System.lineSeparator()));
return lines.isBlank() ? "_None_" : lines;
}
// Package-private (not private) so ModulithDocusaurusRendererTest can exercise the null/blank branches for
// type, defaultValue and description directly, without needing five differently-shaped @ConfigurationProperties
// classes in the sample fixture just to produce every combination.
static String configPropertyLine(ConfigMetadata.ConfigProperty property) {
var line = new StringBuilder("- `").append(property.name()).append('`');
if (property.type() != null && !property.type().isBlank()) {
line.append(" -- `").append(property.type()).append('`');
}
if (property.defaultValue() != null && !property.defaultValue().isBlank()) {
line.append(", default `").append(property.defaultValue()).append('`');
}
if (property.description() != null && !property.description().isBlank()) {
line.append(". ").append(property.description());
}
return line.toString();
}
private static String bulletList(Collection<String> items) {
return items.isEmpty()
? "_None_"
: items.stream().map(item -> "- `" + item + "`").collect(Collectors.joining(System.lineSeparator()));
}
}