add core view extension with JTE and Thymeleaf support
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# flash-ext-view-jte
|
||||
|
||||
Opinionated jte SSR extension for Flash.
|
||||
|
||||
This module keeps jte semantics front and center:
|
||||
|
||||
- `JteExtension`
|
||||
- `JteHandler`
|
||||
- `@Template`
|
||||
- `ViewModel` from `flash-ext-view-core`
|
||||
- `global.*` reserved namespace
|
||||
|
||||
The extension mirrors `gg.jte.ContentType` into Flash HTTP content type:
|
||||
|
||||
- `gg.jte.ContentType.Html` -> `text/html`
|
||||
- `gg.jte.ContentType.Plain` -> `text/plain`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```java
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.ext.view.core.ViewModel;
|
||||
import dev.relism.ext.view.jte.*;
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(new JteExtension(cfg -> cfg
|
||||
.templateRoot("/templates")
|
||||
.contentType(gg.jte.ContentType.Html)))
|
||||
.scan("com.example.web")
|
||||
.startAndBlock();
|
||||
```
|
||||
|
||||
```java
|
||||
import dev.relism.ext.view.core.ViewModel;
|
||||
import dev.relism.ext.view.jte.*;
|
||||
import dev.relism.routing.GET;
|
||||
|
||||
@GET("/")
|
||||
@Template("pages/home.jte")
|
||||
public final class HomePage extends JteHandler {
|
||||
@Override
|
||||
public ViewModel render(dev.relism.models.Request req) {
|
||||
return ViewModel.empty()
|
||||
.with("page", new HomePageModel("Flash + jte", "elorc"))
|
||||
.with("build", "dev");
|
||||
}
|
||||
|
||||
public record HomePageModel(String title, String author) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/architecture.md`
|
||||
- `docs/handlers.md`
|
||||
- `docs/model-and-globals.md`
|
||||
- `docs/performance.md`
|
||||
@@ -0,0 +1,29 @@
|
||||
# Architecture
|
||||
|
||||
`flash-ext-view-jte` has two layers:
|
||||
|
||||
1. **Boot-time**
|
||||
- `JteExtension` installs runtime + annotation processor.
|
||||
- `JteTargetResolver` validates handlers and resolves `@Template`.
|
||||
- Resolved targets are cached per handler class.
|
||||
|
||||
2. **Request-time**
|
||||
- Handler builds local `ViewModel`.
|
||||
- Runtime injects globals under reserved `global` namespace and merges local model.
|
||||
- jte renders template into `StringOutput`.
|
||||
|
||||
## Handler Contract
|
||||
|
||||
- Must extend `JteHandler`.
|
||||
- Must have route annotation (`@Route`, `@GET`, `@POST`, ...).
|
||||
- Must declare exactly one view annotation: `@Template`.
|
||||
|
||||
Invalid configurations fail fast at startup.
|
||||
|
||||
## Defaults
|
||||
|
||||
- `templateRoot`: `/templates`
|
||||
- `contentType`: `gg.jte.ContentType.Html`
|
||||
- `developmentMode`: `Flash.DEV`
|
||||
- `usePrecompiled`: derived from `!developmentMode` unless explicitly set
|
||||
- `binaryStaticContent`: `false`
|
||||
@@ -0,0 +1,34 @@
|
||||
# Handlers
|
||||
|
||||
Use `JteHandler` for class-based jte routes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `onViewInit()` runs once at boot.
|
||||
- `render(...)` runs per request.
|
||||
|
||||
Use `onViewInit()` to cache dependencies via `require(...)`.
|
||||
|
||||
## Example
|
||||
|
||||
```java
|
||||
@GET("/dashboard")
|
||||
@Template("pages/dashboard.jte")
|
||||
public final class DashboardPage extends JteHandler {
|
||||
|
||||
private DashboardService service;
|
||||
|
||||
@Override
|
||||
protected void onViewInit() {
|
||||
service = require(DashboardService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewModel render(dev.relism.models.Request req) {
|
||||
return ViewModel.empty()
|
||||
.with("page", service.page(req));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `render(Request, Response)` when you need response access while building model variables.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Model And Globals
|
||||
|
||||
`ViewModel` is the per-request data container.
|
||||
|
||||
## POJO-First Pattern
|
||||
|
||||
Keep page state in a POJO and pass it as a single key:
|
||||
|
||||
```java
|
||||
return ViewModel.empty()
|
||||
.with("page", new HomePage("Flash + jte", "elorc"))
|
||||
.with("build", "dev");
|
||||
```
|
||||
|
||||
Then type it in template:
|
||||
|
||||
```jte
|
||||
@import com.example.HomePage
|
||||
@param HomePage page
|
||||
@param String build
|
||||
```
|
||||
|
||||
## Globals
|
||||
|
||||
Register globals in extension setup:
|
||||
|
||||
```java
|
||||
new JteExtension().addGlobal("appName", req -> "Flash")
|
||||
```
|
||||
|
||||
Globals are available under `global` namespace in templates.
|
||||
|
||||
`global` is reserved and cannot be used as local model key.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Performance Notes
|
||||
|
||||
- Runtime caches resolved targets by handler class.
|
||||
- Request path allocates only what jte rendering requires.
|
||||
- No reflection on hot path after target cache is warm.
|
||||
- `TemplateEngine` is created once and shared.
|
||||
|
||||
## Dev vs Prod
|
||||
|
||||
- Dev mode (`developmentMode=true`) uses code resolver rendering.
|
||||
- Prod mode (`usePrecompiled=true`) uses precompiled classes for startup and render speed.
|
||||
|
||||
## Content Type
|
||||
|
||||
`gg.jte.ContentType` drives both escaping mode and HTTP response content type mirror:
|
||||
|
||||
- `Html` -> context-aware HTML escaping + `text/html`
|
||||
- `Plain` -> plain output + `text/plain`
|
||||
BIN
Binary file not shown.
+29
@@ -0,0 +1,29 @@
|
||||
package gg.jte.generated.ondemand.pages;
|
||||
import dev.relism.ext.view.jte.model.HomePage;
|
||||
import java.util.Map;
|
||||
@SuppressWarnings("unchecked")
|
||||
public final class JtehomeGenerated {
|
||||
public static final String JTE_NAME = "pages/home.jte";
|
||||
public static final int[] JTE_LINE_INFO = {0,0,1,2,2,2,2,6,6,6,6,7,7,7,8,8,8,9,9,9,10,10,10,2,3,4,4,4,4};
|
||||
public static void render(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, HomePage page, String build, Map<String, Object> global) {
|
||||
jteOutput.writeContent("\n<h1>");
|
||||
jteOutput.setContext("h1", null);
|
||||
jteOutput.writeUserContent(page.title());
|
||||
jteOutput.writeContent("</h1>\n<p>");
|
||||
jteOutput.setContext("p", null);
|
||||
jteOutput.writeUserContent(page.author());
|
||||
jteOutput.writeContent("</p>\n<small>");
|
||||
jteOutput.setContext("small", null);
|
||||
jteOutput.writeUserContent(build);
|
||||
jteOutput.writeContent("</small>\n<small>");
|
||||
jteOutput.setContext("small", null);
|
||||
jteOutput.writeUserContent((String) global.get("appName"));
|
||||
jteOutput.writeContent("</small>\n");
|
||||
}
|
||||
public static void renderMap(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, java.util.Map<String, Object> params) {
|
||||
HomePage page = (HomePage)params.get("page");
|
||||
String build = (String)params.get("build");
|
||||
Map<String, Object> global = (Map<String, Object>)params.get("global");
|
||||
render(jteOutput, jteHtmlInterceptor, page, build, global);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.1-indev6</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-view-jte</artifactId>
|
||||
|
||||
<properties>
|
||||
<jacoco.version>0.8.12</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-view-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>gg.jte</groupId>
|
||||
<artifactId>jte</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-report-and-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.ext.view.core.BaseViewExtension;
|
||||
import dev.relism.ext.view.core.GlobalValue;
|
||||
import dev.relism.ext.view.core.ViewRuntimeBridge;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Opinionated jte SSR extension for Flash.
|
||||
*/
|
||||
public final class JteExtension extends BaseViewExtension<JteTarget> {
|
||||
private final JteSettings settings;
|
||||
|
||||
static {
|
||||
ensureJtePresent();
|
||||
}
|
||||
|
||||
public JteExtension() {
|
||||
this(JteSettings.builder().build());
|
||||
}
|
||||
|
||||
public JteExtension(Consumer<JteSettings.Builder> customizer) {
|
||||
JteSettings.Builder builder = JteSettings.builder();
|
||||
java.util.Objects.requireNonNull(customizer, "customizer must not be null").accept(builder);
|
||||
this.settings = builder.build();
|
||||
}
|
||||
|
||||
private JteExtension(JteSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JteExtension addGlobal(String key, Function<Request, Object> resolver) {
|
||||
super.addGlobal(key, resolver);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ViewRuntimeBridge<JteTarget> createRuntime(List<GlobalValue> globals) {
|
||||
return new JteRuntime(settings, globals);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void validateHandlerClass(Class<? extends RequestHandler> handlerClass) {
|
||||
JteTarget target = JteTargetResolver.resolve(handlerClass, settings);
|
||||
if (target == null) {
|
||||
if (JteHandler.class.isAssignableFrom(handlerClass)) {
|
||||
throw new IllegalStateException("JteHandler " + handlerClass.getName() + " must declare @Template");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!JteHandler.class.isAssignableFrom(handlerClass)) {
|
||||
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||
+ " declares @Template but does not extend JteHandler");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureJtePresent() {
|
||||
try {
|
||||
Class.forName("gg.jte.TemplateEngine", false, JteExtension.class.getClassLoader());
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new IllegalStateException("jte is not on the classpath. Add dependency gg.jte:jte:3.2.3", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.ext.view.core.BaseViewHandler;
|
||||
|
||||
/**
|
||||
* Base class for class-based jte handlers.
|
||||
*/
|
||||
public abstract class JteHandler extends BaseViewHandler<JteTarget> {}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.ext.view.core.BaseViewHandler;
|
||||
import dev.relism.ext.view.core.GlobalValue;
|
||||
import dev.relism.ext.view.core.RenderedView;
|
||||
import dev.relism.ext.view.core.ViewModel;
|
||||
import dev.relism.ext.view.core.ViewRuntimeBridge;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
import gg.jte.CodeResolver;
|
||||
import gg.jte.TemplateEngine;
|
||||
import gg.jte.output.StringOutput;
|
||||
import gg.jte.resolve.DirectoryCodeResolver;
|
||||
import gg.jte.resolve.ResourceCodeResolver;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
final class JteRuntime implements ViewRuntimeBridge<JteTarget> {
|
||||
private static final String GLOBAL_NAMESPACE = "global";
|
||||
|
||||
private final JteSettings settings;
|
||||
private final List<GlobalValue> globals;
|
||||
private final ConcurrentHashMap<Class<?>, JteTarget> targets = new ConcurrentHashMap<>();
|
||||
private final TemplateEngine engine;
|
||||
|
||||
JteRuntime(JteSettings settings, List<GlobalValue> globals) {
|
||||
this.settings = settings;
|
||||
this.globals = globals;
|
||||
this.engine = createEngine(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JteTarget resolve(Class<?> handlerClass) {
|
||||
JteTarget cached = targets.get(handlerClass);
|
||||
if (cached != null) return cached;
|
||||
JteTarget resolved = JteTargetResolver.resolve(handlerClass, settings);
|
||||
if (resolved == null) {
|
||||
throw new IllegalStateException("JteHandler " + handlerClass.getName() + " must declare @Template");
|
||||
}
|
||||
targets.put(handlerClass, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderedView render(BaseViewHandler<JteTarget> handler,
|
||||
JteTarget target,
|
||||
Request req,
|
||||
Response res) throws Exception {
|
||||
ViewModel local = handler.renderInternal(req, res);
|
||||
Map<String, Object> merged = merge(req, local).toMap();
|
||||
StringOutput out = new StringOutput(1024);
|
||||
engine.render(target.template(), merged, out);
|
||||
return new RenderedView(out.toString(), target.contentType());
|
||||
}
|
||||
|
||||
private ViewModel merge(Request req, ViewModel local) {
|
||||
LinkedHashMap<String, Object> values = null;
|
||||
|
||||
if (!globals.isEmpty()) {
|
||||
LinkedHashMap<String, Object> globalMap = new LinkedHashMap<>();
|
||||
for (GlobalValue binding : globals) {
|
||||
Object resolved = binding.resolver().apply(req);
|
||||
globalMap.put(binding.key(), ViewModel.unwrapValue(resolved));
|
||||
}
|
||||
if (!globalMap.isEmpty()) {
|
||||
values = new LinkedHashMap<>();
|
||||
values.put(GLOBAL_NAMESPACE, Collections.unmodifiableMap(globalMap));
|
||||
}
|
||||
}
|
||||
|
||||
if (local != null) {
|
||||
Map<String, Object> localMap = local.toMap();
|
||||
if (localMap.containsKey(GLOBAL_NAMESPACE)) {
|
||||
throw new IllegalStateException("ViewModel key 'global' is reserved for framework globals");
|
||||
}
|
||||
if (values == null) return ViewModel.copyOf(local);
|
||||
values.putAll(localMap);
|
||||
}
|
||||
|
||||
if (values == null || values.isEmpty()) return ViewModel.empty();
|
||||
return ViewModel.owned(values);
|
||||
}
|
||||
|
||||
private static TemplateEngine createEngine(JteSettings settings) {
|
||||
TemplateEngine engine;
|
||||
if (settings.usePrecompiled()) {
|
||||
Path path = settings.precompiledClassesPath();
|
||||
engine = Files.isDirectory(path)
|
||||
? TemplateEngine.createPrecompiled(path, settings.contentType())
|
||||
: TemplateEngine.createPrecompiled(settings.contentType());
|
||||
} else {
|
||||
CodeResolver resolver = createResolver(settings.templateRoot(), Thread.currentThread().getContextClassLoader());
|
||||
engine = TemplateEngine.create(resolver, settings.dynamicClassesPath(), settings.contentType());
|
||||
}
|
||||
engine.setBinaryStaticContent(settings.binaryStaticContent());
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static CodeResolver createResolver(String templateRoot, ClassLoader classLoader) {
|
||||
String root = templateRoot.startsWith("/") ? templateRoot.substring(1) : templateRoot;
|
||||
Path maybeDir = Path.of(root);
|
||||
if (Files.isDirectory(maybeDir)) {
|
||||
return new DirectoryCodeResolver(maybeDir);
|
||||
}
|
||||
return new ResourceCodeResolver(root, classLoader);
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.Flash;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* jte runtime settings with Flash-sensitive defaults.
|
||||
*/
|
||||
public final class JteSettings {
|
||||
private final String templateRoot;
|
||||
private final gg.jte.ContentType contentType;
|
||||
private final boolean developmentMode;
|
||||
private final boolean usePrecompiled;
|
||||
private final boolean binaryStaticContent;
|
||||
private final Path dynamicClassesPath;
|
||||
private final Path precompiledClassesPath;
|
||||
|
||||
private JteSettings(Builder b) {
|
||||
this.templateRoot = b.templateRoot;
|
||||
this.contentType = b.contentType;
|
||||
this.developmentMode = b.developmentMode;
|
||||
this.usePrecompiled = b.usePrecompiled;
|
||||
this.binaryStaticContent = b.binaryStaticContent;
|
||||
this.dynamicClassesPath = b.dynamicClassesPath;
|
||||
this.precompiledClassesPath = b.precompiledClassesPath;
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
String templateRoot() { return templateRoot; }
|
||||
gg.jte.ContentType contentType() { return contentType; }
|
||||
boolean developmentMode() { return developmentMode; }
|
||||
boolean usePrecompiled() { return usePrecompiled; }
|
||||
boolean binaryStaticContent() { return binaryStaticContent; }
|
||||
Path dynamicClassesPath() { return dynamicClassesPath; }
|
||||
Path precompiledClassesPath() { return precompiledClassesPath; }
|
||||
|
||||
public static final class Builder {
|
||||
private String templateRoot = "/templates";
|
||||
private gg.jte.ContentType contentType = gg.jte.ContentType.Html;
|
||||
private Boolean developmentMode;
|
||||
private Boolean usePrecompiled;
|
||||
private boolean binaryStaticContent;
|
||||
private Path dynamicClassesPath = Path.of("jte-classes");
|
||||
private Path precompiledClassesPath = Path.of("jte-classes");
|
||||
|
||||
public Builder templateRoot(String templateRoot) {
|
||||
String root = templateRoot == null ? "" : templateRoot.trim();
|
||||
if (root.isEmpty()) throw new IllegalArgumentException("templateRoot must not be blank");
|
||||
this.templateRoot = normalizeResourceRoot(root);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder contentType(gg.jte.ContentType contentType) {
|
||||
this.contentType = java.util.Objects.requireNonNull(contentType, "contentType must not be null");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder developmentMode(boolean developmentMode) {
|
||||
this.developmentMode = developmentMode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder usePrecompiled(boolean usePrecompiled) {
|
||||
this.usePrecompiled = usePrecompiled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder binaryStaticContent(boolean binaryStaticContent) {
|
||||
this.binaryStaticContent = binaryStaticContent;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder dynamicClassesPath(Path dynamicClassesPath) {
|
||||
this.dynamicClassesPath = java.util.Objects.requireNonNull(dynamicClassesPath, "dynamicClassesPath must not be null");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder precompiledClassesPath(Path precompiledClassesPath) {
|
||||
this.precompiledClassesPath = java.util.Objects.requireNonNull(precompiledClassesPath, "precompiledClassesPath must not be null");
|
||||
return this;
|
||||
}
|
||||
|
||||
public JteSettings build() {
|
||||
boolean resolvedDev = developmentMode != null ? developmentMode : Flash.DEV;
|
||||
boolean resolvedPrecompiled = usePrecompiled != null ? usePrecompiled : !resolvedDev;
|
||||
Builder resolved = new Builder();
|
||||
resolved.templateRoot = this.templateRoot;
|
||||
resolved.contentType = this.contentType;
|
||||
resolved.developmentMode = resolvedDev;
|
||||
resolved.usePrecompiled = resolvedPrecompiled;
|
||||
resolved.binaryStaticContent = this.binaryStaticContent;
|
||||
resolved.dynamicClassesPath = this.dynamicClassesPath;
|
||||
resolved.precompiledClassesPath = this.precompiledClassesPath;
|
||||
return new JteSettings(resolved);
|
||||
}
|
||||
|
||||
private static String normalizeResourceRoot(String root) {
|
||||
String normalized = root.replace('\\', '/');
|
||||
if (!normalized.startsWith("/")) normalized = '/' + normalized;
|
||||
while (normalized.endsWith("/")) normalized = normalized.substring(0, normalized.length() - 1);
|
||||
if (normalized.isEmpty()) return "/";
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
record JteTarget(String template, ContentType contentType) {}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.Routes;
|
||||
|
||||
final class JteTargetResolver {
|
||||
private JteTargetResolver() {}
|
||||
|
||||
static JteTarget resolve(Class<?> handlerClass, JteSettings settings) {
|
||||
Template template = find(handlerClass, Template.class);
|
||||
if (template == null) return null;
|
||||
|
||||
Route route = Routes.of(handlerClass);
|
||||
if (route == null) {
|
||||
throw new IllegalStateException("Jte handler " + handlerClass.getName()
|
||||
+ " has @Template but no route annotation (@Route/@GET/@POST/...)");
|
||||
}
|
||||
|
||||
String name = template.value() == null ? "" : template.value().trim();
|
||||
if (name.isEmpty()) {
|
||||
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||
+ " route " + route.method() + " " + route.path()
|
||||
+ " uses @Template with an empty value");
|
||||
}
|
||||
|
||||
return new JteTarget(name, toHttpContentType(settings.contentType()));
|
||||
}
|
||||
|
||||
private static ContentType toHttpContentType(gg.jte.ContentType contentType) {
|
||||
return contentType == gg.jte.ContentType.Plain ? ContentType.TEXT_PLAIN : ContentType.TEXT_HTML;
|
||||
}
|
||||
|
||||
private static <A extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
|
||||
while (cls != null && !cls.equals(Object.class)) {
|
||||
A a = cls.getAnnotation(type);
|
||||
if (a != null) return a;
|
||||
cls = cls.getSuperclass();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Binds a handler to a jte template.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Template {
|
||||
String value();
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.routing.GET;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JteExtensionTest {
|
||||
|
||||
@GET("/ok")
|
||||
@Template("pages/home.jte")
|
||||
static class ValidHandler extends JteHandler {
|
||||
@Override
|
||||
public dev.relism.ext.view.core.ViewModel render(Request req) {
|
||||
return dev.relism.ext.view.core.ViewModel.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/wrong")
|
||||
@Template("pages/home.jte")
|
||||
static class WrongBaseHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/missing")
|
||||
static class MissingTemplateHandler extends JteHandler {}
|
||||
|
||||
@Test
|
||||
void constructor_requiresNonNullCustomizer() {
|
||||
assertThrows(NullPointerException.class, () -> new JteExtension(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_acceptsProperHandler() throws Exception {
|
||||
JteExtension ext = new JteExtension();
|
||||
invokeValidate(ext, ValidHandler.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_rejectsTemplateWithoutJteHandlerBase() {
|
||||
JteExtension ext = new JteExtension();
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> invokeValidate(ext, WrongBaseHandler.class));
|
||||
|
||||
assertTrue(ex.getMessage().contains("does not extend JteHandler"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_rejectsJteHandlerWithoutTemplate() {
|
||||
JteExtension ext = new JteExtension();
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> invokeValidate(ext, MissingTemplateHandler.class));
|
||||
|
||||
assertTrue(ex.getMessage().contains("must declare @Template"));
|
||||
}
|
||||
|
||||
private static void invokeValidate(JteExtension ext, Class<? extends RequestHandler> type) throws Exception {
|
||||
Method m = JteExtension.class.getDeclaredMethod("validateHandlerClass", Class.class);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
m.invoke(ext, type);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof RuntimeException re) throw re;
|
||||
if (cause instanceof Error err) throw err;
|
||||
throw new RuntimeException(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.ext.view.core.GlobalValue;
|
||||
import dev.relism.ext.view.core.RenderedView;
|
||||
import dev.relism.ext.view.core.ViewModel;
|
||||
import dev.relism.ext.view.jte.model.HomePage;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JteRuntimeTest {
|
||||
|
||||
@Test
|
||||
void render_template_rendersPojoAndGlobals() throws Exception {
|
||||
JteRuntime runtime = new JteRuntime(
|
||||
JteSettings.builder().templateRoot("/templates").developmentMode(true).build(),
|
||||
List.of(new GlobalValue("appName", req -> "Flash"))
|
||||
);
|
||||
|
||||
RenderedView out = runtime.render(new JteHandler() {
|
||||
@Override
|
||||
public ViewModel render(Request req) {
|
||||
return ViewModel.empty()
|
||||
.with("page", new HomePage("Flash + jte", "elorc"))
|
||||
.with("build", "dev");
|
||||
}
|
||||
}, new JteTarget("pages/home.jte", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON));
|
||||
|
||||
assertEquals(ContentType.TEXT_HTML, out.contentType());
|
||||
assertTrue(out.body().contains("Flash + jte"));
|
||||
assertTrue(out.body().contains("elorc"));
|
||||
assertTrue(out.body().contains("Flash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_rejectsReservedGlobalKeyInLocalModel() {
|
||||
JteRuntime runtime = new JteRuntime(
|
||||
JteSettings.builder().templateRoot("/templates").developmentMode(true).build(),
|
||||
List.of(new GlobalValue("appName", req -> "Flash"))
|
||||
);
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
|
||||
runtime.render(new JteHandler() {
|
||||
@Override
|
||||
public ViewModel render(Request req) {
|
||||
return ViewModel.of("global", "bad");
|
||||
}
|
||||
}, new JteTarget("pages/home.jte", ContentType.TEXT_HTML), null, new Response(200, ContentType.JSON))
|
||||
);
|
||||
|
||||
assertTrue(ex.getMessage().contains("reserved"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_missingTemplateOnHandler_failsFast() {
|
||||
JteRuntime runtime = new JteRuntime(JteSettings.builder().templateRoot("/templates").developmentMode(true).build(), List.of());
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runtime.resolve(NoTemplateHandler.class));
|
||||
assertTrue(ex.getMessage().contains("must declare @Template"));
|
||||
}
|
||||
|
||||
static class NoTemplateHandler extends JteHandler {}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.Flash;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JteSettingsTest {
|
||||
|
||||
@Test
|
||||
void defaults_followFlashDev_andUseHtml() {
|
||||
JteSettings settings = JteSettings.builder().build();
|
||||
|
||||
assertEquals("/templates", settings.templateRoot());
|
||||
assertEquals(gg.jte.ContentType.Html, settings.contentType());
|
||||
assertEquals(Flash.DEV, settings.developmentMode());
|
||||
assertEquals(!Flash.DEV, settings.usePrecompiled());
|
||||
assertFalse(settings.binaryStaticContent());
|
||||
assertEquals(Path.of("jte-classes"), settings.dynamicClassesPath());
|
||||
assertEquals(Path.of("jte-classes"), settings.precompiledClassesPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitOverrides_winOverDerivedDefaults() {
|
||||
JteSettings settings = JteSettings.builder()
|
||||
.templateRoot("src/main/jte")
|
||||
.contentType(gg.jte.ContentType.Plain)
|
||||
.developmentMode(true)
|
||||
.usePrecompiled(true)
|
||||
.binaryStaticContent(true)
|
||||
.dynamicClassesPath(Path.of("var", "jte-dev"))
|
||||
.precompiledClassesPath(Path.of("var", "jte-prod"))
|
||||
.build();
|
||||
|
||||
assertEquals("/src/main/jte", settings.templateRoot());
|
||||
assertEquals(gg.jte.ContentType.Plain, settings.contentType());
|
||||
assertTrue(settings.developmentMode());
|
||||
assertTrue(settings.usePrecompiled());
|
||||
assertTrue(settings.binaryStaticContent());
|
||||
assertEquals(Path.of("var", "jte-dev"), settings.dynamicClassesPath());
|
||||
assertEquals(Path.of("var", "jte-prod"), settings.precompiledClassesPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usePrecompiled_derivesFromResolvedDevModeWhenMissing() {
|
||||
JteSettings dev = JteSettings.builder().developmentMode(true).build();
|
||||
JteSettings prod = JteSettings.builder().developmentMode(false).build();
|
||||
|
||||
assertFalse(dev.usePrecompiled());
|
||||
assertTrue(prod.usePrecompiled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void templateRoot_normalizesAndRejectsBlank() {
|
||||
JteSettings normalized = JteSettings.builder().templateRoot("templates").build();
|
||||
assertEquals("/templates", normalized.templateRoot());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().templateRoot(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nulls_areRejectedForRequiredObjects() {
|
||||
assertThrows(NullPointerException.class, () -> JteSettings.builder().contentType(null));
|
||||
assertThrows(NullPointerException.class, () -> JteSettings.builder().dynamicClassesPath(null));
|
||||
assertThrows(NullPointerException.class, () -> JteSettings.builder().precompiledClassesPath(null));
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.relism.ext.view.jte;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.routing.GET;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JteTargetResolverTest {
|
||||
|
||||
@GET("/home")
|
||||
@Template("pages/home.jte")
|
||||
static class TemplateHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Template("pages/no-route.jte")
|
||||
static class NoRouteHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/empty")
|
||||
@Template(" ")
|
||||
static class EmptyTemplateHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_template_returnsTarget_withMirroredHttpContentType() {
|
||||
JteSettings htmlSettings = JteSettings.builder().contentType(gg.jte.ContentType.Html).build();
|
||||
JteSettings plainSettings = JteSettings.builder().contentType(gg.jte.ContentType.Plain).build();
|
||||
|
||||
JteTarget html = JteTargetResolver.resolve(TemplateHandler.class, htmlSettings);
|
||||
JteTarget plain = JteTargetResolver.resolve(TemplateHandler.class, plainSettings);
|
||||
|
||||
assertNotNull(html);
|
||||
assertEquals("pages/home.jte", html.template());
|
||||
assertEquals(ContentType.TEXT_HTML, html.contentType());
|
||||
assertEquals(ContentType.TEXT_PLAIN, plain.contentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_withoutTemplate_returnsNull() {
|
||||
assertNull(JteTargetResolver.resolve(RequestHandler.class, JteSettings.builder().build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_templateWithoutRoute_fails() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> JteTargetResolver.resolve(NoRouteHandler.class, JteSettings.builder().build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_emptyTemplate_fails() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> JteTargetResolver.resolve(EmptyTemplateHandler.class, JteSettings.builder().build()));
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package dev.relism.ext.view.jte.model;
|
||||
|
||||
public record HomePage(String title, String author) {}
|
||||
@@ -0,0 +1,10 @@
|
||||
@import dev.relism.ext.view.jte.model.HomePage
|
||||
@import java.util.Map
|
||||
@param HomePage page
|
||||
@param String build
|
||||
@param Map<String, Object> global
|
||||
|
||||
<h1>${page.title()}</h1>
|
||||
<p>${page.author()}</p>
|
||||
<small>${build}</small>
|
||||
<small>${(String) global.get("appName")}</small>
|
||||
Reference in New Issue
Block a user