diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java index 356d222..bf5f3e5 100644 --- a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java @@ -15,6 +15,23 @@ import java.util.function.Function; public abstract class BaseViewExtension implements FlashExtension { private final List globals = new ArrayList<>(); + protected BaseViewExtension() {} + + /** + * Seeds this instance with globals carried over from a prior one. Subclasses whose + * fluent settings methods return a new instance (e.g. {@code JteExtension.templateRoot(...)}) + * must route through this constructor — otherwise {@link #addGlobal} calls made before + * such a method silently vanish, since the new instance would start with an empty list. + */ + protected BaseViewExtension(List seedGlobals) { + globals.addAll(seedGlobals); + } + + /** Snapshot of globals registered so far — for subclasses to carry over into a new instance. */ + protected final List globals() { + return List.copyOf(globals); + } + public BaseViewExtension addGlobal(String key, Function resolver) { String k = Objects.requireNonNull(key, "global key must not be null").trim(); if (k.isEmpty()) { @@ -34,7 +51,13 @@ public abstract class BaseViewExtension implements FlashExtension { @Override public void configure(FlashRegistrar app, FlashContext ctx) { ViewRuntimeBridge runtime = createRuntime(List.copyOf(globals)); - ctx.provide(ViewRuntimeBridge.class, runtime); + try { + ctx.provide(ViewRuntimeBridge.class, runtime); + } catch (IllegalStateException e) { + throw new IllegalStateException("Another flash-ext-view implementation is already installed in " + + "this FlashApp. Only one view engine (e.g. ThymeleafExtension or JteExtension, not both) " + + "can be active per app — install a single implementation.", e); + } ctx.addAnnotationProcessor(handlerClass -> { validateHandlerClass(handlerClass); return List.of(); diff --git a/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java new file mode 100644 index 0000000..0e669d3 --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java @@ -0,0 +1,40 @@ +package dev.relism.flash.ext.view.core; + +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.models.RequestHandler; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class BaseViewExtensionTest { + + private static final class FakeViewExtension extends BaseViewExtension { + @Override + protected ViewRuntimeBridge createRuntime(List globals) { + return new ViewRuntimeBridge<>() { + @Override public Object resolve(Class handlerClass) { return null; } + @Override public RenderedView render(BaseViewHandler handler, Object target, + dev.relism.flash.models.Request req, + dev.relism.flash.models.Response res) { return null; } + }; + } + + @Override + protected void validateHandlerClass(Class handlerClass) {} + } + + @Test + void configure_twoViewEngines_failsWithClearMessage() { + FlashContext ctx = new FlashContext(); + new FakeViewExtension().configure(null, ctx); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> new FakeViewExtension().configure(null, ctx)); + + assertTrue(ex.getMessage().contains("Only one view engine"), + "expected a clear cross-engine message, got: " + ex.getMessage()); + } +} diff --git a/flash-extensions/flash-ext-view-jte/README.md b/flash-extensions/flash-ext-view-jte/README.md index 3365142..bb0aba8 100644 --- a/flash-extensions/flash-ext-view-jte/README.md +++ b/flash-extensions/flash-ext-view-jte/README.md @@ -8,7 +8,7 @@ This module keeps jte semantics front and center: - `JteHandler` - `@Template` - `ViewModel` from `flash-ext-view-core` -- `global.*` reserved namespace +- globals merged flat into the model, one typed `@param` per global The extension mirrors `gg.jte.ContentType` into Flash HTTP content type: diff --git a/flash-extensions/flash-ext-view-jte/docs/architecture.md b/flash-extensions/flash-ext-view-jte/docs/architecture.md index 5acd87f..eda94c4 100644 --- a/flash-extensions/flash-ext-view-jte/docs/architecture.md +++ b/flash-extensions/flash-ext-view-jte/docs/architecture.md @@ -9,7 +9,7 @@ 2. **Request-time** - Handler builds local `ViewModel`. - - Runtime injects globals under reserved `global` namespace and merges local model. + - Runtime merges globals and local model into one flat parameter map (fails fast on key collision). - jte renders template into `StringOutput`. 3. **Static assets (optional, enabled by default)** diff --git a/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md index 08b4e82..affd85f 100644 --- a/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md +++ b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md @@ -28,6 +28,21 @@ Register globals in extension setup: new JteExtension().addGlobal("appName", req -> "Flash") ``` -Globals are available under `global` namespace in templates. +Globals are merged **flat** into the model — same level as local `ViewModel` keys, not +under a `global.*` namespace. jte templates are statically typed, so each template declares +one `@param` per global it actually uses, named exactly like the global key: -`global` is reserved and cannot be used as local model key. +```jte +@param String appName + +${appName} +``` + +A template that doesn't declare `appName` simply never sees it — no need to declare every +global on every page, only the ones a given template actually uses. + +A local `ViewModel` key that collides with a registered global name fails fast with +`IllegalStateException` at render time, so a typo can't silently shadow a global. + +Every registered global is still resolved on **every** render regardless of whether the +target template declares it, so keep resolvers cheap (no blocking I/O, no heavy allocation). diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java index 832bac2..e36ea2e 100644 --- a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java @@ -29,31 +29,32 @@ public final class JteExtension extends BaseViewExtension { this.settings = builder.build(); } - private JteExtension(JteSettings settings) { + private JteExtension(JteSettings settings, List seedGlobals) { + super(seedGlobals); this.settings = settings; } public JteExtension templateRoot(String templateRoot) { - return new JteExtension(settings.toBuilder().templateRoot(templateRoot).build()); + return new JteExtension(settings.toBuilder().templateRoot(templateRoot).build(), globals()); } public JteExtension serveStatics(boolean serveStatics) { - return new JteExtension(settings.toBuilder().serveStatics(serveStatics).build()); + return new JteExtension(settings.toBuilder().serveStatics(serveStatics).build(), globals()); } public JteExtension staticPrefix(String staticPrefix) { - return new JteExtension(settings.toBuilder().staticPrefix(staticPrefix).build()); + return new JteExtension(settings.toBuilder().staticPrefix(staticPrefix).build(), globals()); } public JteExtension withStaticCors() { - return new JteExtension(settings.toBuilder().enableStaticCors(true).build()); + return new JteExtension(settings.toBuilder().enableStaticCors(true).build(), globals()); } public JteExtension staticCors(Consumer corsConfig) { JteSettings.Builder builder = settings.toBuilder(); builder.enableStaticCors(true); corsConfig.accept(builder); - return new JteExtension(builder.build()); + return new JteExtension(builder.build(), globals()); } @Override diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java index bb80188..9e1197e 100644 --- a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java @@ -1,6 +1,11 @@ package dev.relism.flash.ext.view.jte; +import dev.relism.flash.ext.view.core.RenderedView; import dev.relism.flash.ext.view.core.ViewModel; +import dev.relism.flash.ext.view.core.ViewRuntimeBridge; +import dev.relism.flash.ext.view.jte.model.HomePage; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.http.ContentType; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.Response; @@ -89,6 +94,39 @@ class JteExtensionTest { assertEquals("https://cdn.example.com", settings.staticCorsAllowOrigin()); } + @Test + void addGlobal_survives_subsequent_settings_builder_calls() throws Exception { + // addGlobal() before a chained settings method (templateRoot/serveStatics/...) used to + // be silently dropped: those methods return a *new* JteExtension instance, and the + // globals list lived on the instance, not the settings being carried forward. + JteExtension ext = new JteExtension(cfg -> cfg.developmentMode(true)) + .addGlobal("appName", req -> "FlashLab") + .templateRoot("templates") + .serveStatics(false); + + TestRegistrar app = new TestRegistrar(); + FlashContext ctx = new FlashContext(); + ext.configure(app, ctx); + ctx.complete(); + + @SuppressWarnings("unchecked") + ViewRuntimeBridge runtime = (ViewRuntimeBridge) ctx.require(ViewRuntimeBridge.class); + + JteHandler handler = new JteHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.empty() + .with("page", new HomePage("Flash + jte", "elorc")) + .with("build", "dev"); + } + }; + JteTarget target = new JteTarget("pages/home.jte", ContentType.TEXT_HTML); + RenderedView out = runtime.render(handler, target, request("/", null, null, null), + new Response(200, ContentType.JSON)); + + assertTrue(out.body().contains("FlashLab"), "global registered before templateRoot() must survive"); + } + @Test void routes_register_static_wildcard_when_enabled() { JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets"));