diff --git a/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebProcessor.java b/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebProcessor.java index b2b4eaa4..ebcf169d 100644 --- a/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebProcessor.java +++ b/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebProcessor.java @@ -1,5 +1,7 @@ package io.quarkiverse.qute.web.deployment; +import static java.util.function.Predicate.not; + import java.util.List; import java.util.Optional; import java.util.regex.Pattern; @@ -42,7 +44,7 @@ AdditionalBeanBuildItem beans() { @BuildStep public void collectTemplatePaths(TemplateFilePathsBuildItem templateFilePaths, - QuteWebBuildTimeConfig config, BuildProducer paths) { + QuteWebBuildTimeConfig config, BuildProducer paths) { String publicPathPrefix = ""; String publicDir = config.publicDir(); if (!publicDir.equals("/") && !publicDir.isBlank()) { @@ -60,24 +62,28 @@ public void collectTemplatePaths(TemplateFilePathsBuildItem templateFilePaths, continue; } LOG.debugf("Web template found: %s", path); - paths.produce(new QuteWebTemplatePathBuildItem(path)); + paths.produce(new QuteWebTemplateBuildItem(path, null)); } } @BuildStep @Record(ExecutionTime.RUNTIME_INIT) @Consume(SyntheticBeansRuntimeInitBuildItem.class) - public RouteBuildItem produceTemplatesRoute(QuteWebRecorder recorder, List templatePaths, + public RouteBuildItem produceTemplatesRoute(QuteWebRecorder recorder, List templates, HttpRootPathBuildItem httpRootPath, QuteWebBuildTimeConfig config) { - if (templatePaths.isEmpty()) { + if (templates.isEmpty()) { // There are no templates to serve return null; } + final var templateLinks = templates.stream().filter(QuteWebTemplateBuildItem::hasLink) + .collect(Collectors.toMap(QuteWebTemplateBuildItem::link, QuteWebTemplateBuildItem::templatePath)); + final var templatePaths = templates.stream().filter(not(QuteWebTemplateBuildItem::hasLink)) + .map(QuteWebTemplateBuildItem::templatePath) + .collect(Collectors.toSet()); return httpRootPath.routeBuilder() .routeFunction(httpRootPath.relativePath(config.rootPath() + "/*"), recorder.initializeRoute()) .handlerType(config.useBlockingHandler() ? HandlerType.BLOCKING : HandlerType.NORMAL) - .handler(recorder.handler(httpRootPath.relativePath(config.rootPath()), - templatePaths.stream().map(QuteWebTemplatePathBuildItem::getPath).collect(Collectors.toSet()))) + .handler(recorder.handler(httpRootPath.relativePath(config.rootPath()), templatePaths, templateLinks)) .build(); } } diff --git a/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebTemplateBuildItem.java b/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebTemplateBuildItem.java new file mode 100644 index 00000000..211df8ff --- /dev/null +++ b/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebTemplateBuildItem.java @@ -0,0 +1,48 @@ +package io.quarkiverse.qute.web.deployment; + +import static io.quarkiverse.qute.web.runtime.PathUtils.removeExtension; +import static io.quarkiverse.qute.web.runtime.PathUtils.removeLeadingSlash; +import static io.quarkiverse.qute.web.runtime.PathUtils.removeTrailingSlash; + +import io.quarkus.builder.item.MultiBuildItem; + +public final class QuteWebTemplateBuildItem extends MultiBuildItem { + + /** + * templatePath is used also as path if link is null (e.g. "my-blog-post") + */ + private final String templatePath; + + /** + * The link to use for this template or null to use the template path (e.g "posts/my-blog-post") + * + * If two links are identical, an exception is thrown + * If a link and a path are identical for different items, the link has priority + */ + private final String link; + + public QuteWebTemplateBuildItem(String templatePath, String link) { + this.templatePath = removeExtension(templatePath); + this.link = normalizeLink(link); + } + + public String templatePath() { + return templatePath; + } + + public String link() { + return link; + } + + public boolean hasLink() { + return link != null; + } + + private static String normalizeLink(String link) { + if (link == null) { + return null; + } + return removeTrailingSlash(removeLeadingSlash(link)); + } + +} diff --git a/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebTemplatePathBuildItem.java b/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebTemplatePathBuildItem.java deleted file mode 100644 index d7f73f10..00000000 --- a/deployment/src/main/java/io/quarkiverse/qute/web/deployment/QuteWebTemplatePathBuildItem.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.quarkiverse.qute.web.deployment; - -import io.quarkus.builder.item.MultiBuildItem; - -public final class QuteWebTemplatePathBuildItem extends MultiBuildItem { - - private final String path; - - public QuteWebTemplatePathBuildItem(String path) { - this.path = path; - } - - public String getPath() { - return path; - } - -} diff --git a/deployment/src/main/java/io/quarkiverse/qute/web/deployment/devui/QuteWebDevUIProcessor.java b/deployment/src/main/java/io/quarkiverse/qute/web/deployment/devui/QuteWebDevUIProcessor.java index e3c955f9..fc01cfe4 100644 --- a/deployment/src/main/java/io/quarkiverse/qute/web/deployment/devui/QuteWebDevUIProcessor.java +++ b/deployment/src/main/java/io/quarkiverse/qute/web/deployment/devui/QuteWebDevUIProcessor.java @@ -2,9 +2,9 @@ import java.util.Comparator; import java.util.List; -import java.util.stream.Collectors; -import io.quarkiverse.qute.web.deployment.QuteWebTemplatePathBuildItem; +import io.quarkiverse.qute.web.deployment.QuteWebTemplateBuildItem; +import io.quarkiverse.qute.web.runtime.PathUtils; import io.quarkiverse.qute.web.runtime.QuteWebBuildTimeConfig; import io.quarkus.deployment.IsDevelopment; import io.quarkus.deployment.annotations.BuildProducer; @@ -13,23 +13,25 @@ import io.quarkus.devui.spi.page.Page; import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; public class QuteWebDevUIProcessor { @BuildStep(onlyIf = IsDevelopment.class) - public void pages(List templatePaths, HttpRootPathBuildItem httpRootPath, + public void pages(List templatePaths, HttpRootPathBuildItem httpRootPath, QuteWebBuildTimeConfig config, BuildProducer cardPages) { CardPageBuildItem pageBuildItem = new CardPageBuildItem(); - + final String publicDir = config.publicDir(); JsonArray paths = new JsonArray(); - for (String path : templatePaths.stream().map(QuteWebTemplatePathBuildItem::getPath) - .sorted(Comparator.comparing(p -> p.toLowerCase())).collect(Collectors.toList())) { - paths.add(path); + for (QuteWebTemplateBuildItem item : templatePaths.stream() + .sorted(Comparator.comparing(p -> p.templatePath().toLowerCase())).toList()) { + var link = item.link() == null ? PathUtils.removeLeadingSlash(item.templatePath().replace(publicDir, "")) + : item.link(); + link = PathUtils.join(httpRootPath.relativePath(config.rootPath()), link); + paths.add(new JsonObject().put("templateId", item.templatePath()).put("link", link)); } - pageBuildItem.addBuildTimeData("paths", paths); - pageBuildItem.addBuildTimeData("rootPrefix", httpRootPath.relativePath(config.rootPath()) + "/"); pageBuildItem.addPage(Page.webComponentPageBuilder() .title("Pages") diff --git a/deployment/src/main/resources/dev-ui/qwc-qsp-paths.js b/deployment/src/main/resources/dev-ui/qwc-qsp-paths.js index e6446d90..bf199575 100644 --- a/deployment/src/main/resources/dev-ui/qwc-qsp-paths.js +++ b/deployment/src/main/resources/dev-ui/qwc-qsp-paths.js @@ -4,7 +4,6 @@ import { columnBodyRenderer } from '@vaadin/grid/lit.js'; import '@vaadin/grid'; import '@vaadin/text-field'; import { paths } from 'build-time-data'; -import { rootPrefix } from 'build-time-data'; /** @@ -33,18 +32,22 @@ export class QwcQspPaths extends LitElement { render() { return html` - + + `; } - _renderPath(path) { + _renderLink(item) { return html` - ${rootPrefix}${path} + ${item.link} `; } diff --git a/deployment/src/test/java/io/quarkiverse/qute/web/test/LinkedDuplicateTemplateTest.java b/deployment/src/test/java/io/quarkiverse/qute/web/test/LinkedDuplicateTemplateTest.java new file mode 100644 index 00000000..25c07453 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/qute/web/test/LinkedDuplicateTemplateTest.java @@ -0,0 +1,38 @@ +package io.quarkiverse.qute.web.test; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.qute.web.deployment.QuteWebTemplateBuildItem; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.builder.BuildStepBuilder; +import io.quarkus.test.QuarkusUnitTest; + +public class LinkedDuplicateTemplateTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().withApplicationRoot(root -> { + root.addAsResource(new StringAsset( + "Hello {name ?: 'world'}!"), + "templates/pub/hello.txt"); + }).addBuildChainCustomizer(buildChainBuilder -> { + final BuildStepBuilder stepBuilder = buildChainBuilder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + context.produce(new QuteWebTemplateBuildItem("pub/hello.txt", "/foo/bar")); + context.produce(new QuteWebTemplateBuildItem("pub/hello.txt", "/foo/bar")); + } + }); + stepBuilder.produces(QuteWebTemplateBuildItem.class).build(); + }).assertException(throwable -> { + Assertions.assertInstanceOf(IllegalStateException.class, throwable); + }); + + @Test + public void testDuplicateLink() { + + } +} diff --git a/deployment/src/test/java/io/quarkiverse/qute/web/test/LinkedTemplateTest.java b/deployment/src/test/java/io/quarkiverse/qute/web/test/LinkedTemplateTest.java new file mode 100644 index 00000000..3d636769 --- /dev/null +++ b/deployment/src/test/java/io/quarkiverse/qute/web/test/LinkedTemplateTest.java @@ -0,0 +1,57 @@ +package io.quarkiverse.qute.web.test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.qute.web.deployment.QuteWebTemplateBuildItem; +import io.quarkus.builder.BuildContext; +import io.quarkus.builder.BuildStep; +import io.quarkus.builder.BuildStepBuilder; +import io.quarkus.test.QuarkusUnitTest; + +public class LinkedTemplateTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest().withApplicationRoot(root -> { + root.addAsResource(new StringAsset( + "Hello {name ?: 'world'}!"), + "templates/pub/hello.txt"); + root.addAsResource(new StringAsset( + "Linked Hello {name ?: 'world'}!"), + "templates/linked.txt"); + }).addBuildChainCustomizer(buildChainBuilder -> { + final BuildStepBuilder stepBuilder = buildChainBuilder.addBuildStep(new BuildStep() { + @Override + public void execute(BuildContext context) { + context.produce(new QuteWebTemplateBuildItem("pub/hello.txt", "/foo/bar")); + context.produce(new QuteWebTemplateBuildItem("pub/hello.txt", "/")); + context.produce(new QuteWebTemplateBuildItem("linked.txt", "/hello.txt")); + } + }); + stepBuilder.produces(QuteWebTemplateBuildItem.class).build(); + }); + + @Test + public void testFixedLink() { + given() + .when().get("/hello") + .then() + .statusCode(200) + .body(containsString("Hello world!")); + given() + .when().get("/foo/bar") + .then() + .statusCode(200) + .body(containsString("Hello world!")); + given() + .when().get("/hello.txt") + .then() + .statusCode(200) + .body(containsString("Linked Hello world!")); + + } +} diff --git a/runtime/src/main/java/io/quarkiverse/qute/web/runtime/PathUtils.java b/runtime/src/main/java/io/quarkiverse/qute/web/runtime/PathUtils.java new file mode 100644 index 00000000..d6ef758f --- /dev/null +++ b/runtime/src/main/java/io/quarkiverse/qute/web/runtime/PathUtils.java @@ -0,0 +1,37 @@ +package io.quarkiverse.qute.web.runtime; + +public final class PathUtils { + + public static String toUnixPath(String path) { + return path.replaceAll("\\\\", "/"); + } + + public static String prefixWithSlash(String path) { + return path.startsWith("/") ? path : "/" + path; + } + + public static String surroundWithSlashes(String path) { + return prefixWithSlash(addTrailingSlash(path)); + } + + public static String addTrailingSlash(String path) { + return path.endsWith("/") ? path : path + "/"; + } + + public static String join(String path1, String path2) { + return addTrailingSlash(path1) + removeLeadingSlash(path2); + } + + public static String removeLeadingSlash(String path) { + return path.startsWith("/") ? path.substring(1) : path; + } + + public static String removeTrailingSlash(String path) { + return path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + } + + public static String removeExtension(String path) { + final int i = path.lastIndexOf("."); + return i > 0 ? path.substring(0, i) : path; + } +} diff --git a/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebHandler.java b/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebHandler.java index 560267c2..ecf1dae6 100644 --- a/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebHandler.java +++ b/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebHandler.java @@ -1,8 +1,10 @@ package io.quarkiverse.qute.web.runtime; +import static io.quarkiverse.qute.web.runtime.PathUtils.removeLeadingSlash; +import static io.quarkiverse.qute.web.runtime.PathUtils.removeTrailingSlash; + import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @@ -40,8 +42,8 @@ public class QuteWebHandler implements Handler { private static final String FRAGMENT_PARAM = "frag"; private final String rootPath; - private final String webTemplatesPath; private final Set templatePaths; + private final String webTemplatesPath; private final List compressMediaTypes; // request path to template path private final Map extractedPaths; @@ -52,16 +54,18 @@ public class QuteWebHandler implements Handler { private final ManagedContext requestContext; private final LazyValue templateProducer; private final QuteContext quteContext; + private final Map templateLinks; - public QuteWebHandler(String rootPath, String publicDir, Set templatePaths, + public QuteWebHandler(String rootPath, String publicDir, Set templatePaths, Map templateLinks, HttpBuildTimeConfig httpBuildTimeConfig) { this.rootPath = rootPath; + this.templatePaths = templatePaths; if (publicDir.equals("/") || publicDir.isBlank()) { this.webTemplatesPath = ""; } else { this.webTemplatesPath = publicDir.startsWith("/") ? publicDir.substring(1) : publicDir; } - this.templatePaths = templatePaths; + this.templateLinks = templateLinks; this.compressMediaTypes = httpBuildTimeConfig.enableCompression ? httpBuildTimeConfig.compressMediaTypes.orElse(List.of()) : null; @@ -126,8 +130,7 @@ private void handlePage(RoutingContext rc) { // Extract the real template path, e.g. /item.html -> web/item String path = extractedPaths.computeIfAbsent(requestPath, this::extractTemplatePath); - - if (path != null && templatePaths.contains(path)) { + if (path != null) { Template template = templateProducer.get().getInjectableTemplate(path); TemplateInstance originalInstance = template.instance(); TemplateInstance instance = originalInstance; @@ -224,7 +227,7 @@ private Variant trySelectVariant(RoutingContext rc, TemplateInstance instance, L *
  • {@code /qsp/item?id=1} => {@code web/item}
  • *
  • {@code /nested/item.html?foo=bar} => {@code web/nested/item}
  • * - * + *

    * Note that a path that ends with {@code /} is handled specifically. The {@code index} is appended to the path. * * @param path @@ -232,26 +235,36 @@ private Variant trySelectVariant(RoutingContext rc, TemplateInstance instance, L */ private String extractTemplatePath(String path) { if (path.length() >= rootPath.length()) { - if (path.endsWith("/")) { - path = path + "index"; - } path = path.substring(rootPath.length()); - if (path.startsWith("/")) { - // /item.html => item.html - path = path.substring(1); + path = removeLeadingSlash(path); + + // Check if we have a matching linked template + final String link = removeTrailingSlash(path); + if (templateLinks.containsKey(link)) { + return templateLinks.get(link); } + + // Check if we have a matching template path + if (path.isEmpty()) { + path = "index"; + } else if (path.endsWith("/")) { + path = path + "index"; + } + if (!webTemplatesPath.isEmpty()) { path = webTemplatesPath + "/" + path; } if (path.contains(".")) { - for (Entry> e : quteContext.getVariants().entrySet()) { + for (Map.Entry> e : quteContext.getVariants().entrySet()) { if (e.getValue().contains(path)) { path = e.getKey(); break; } } } - return path; + if (templatePaths.contains(path)) { + return path; + } } return null; } diff --git a/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebRecorder.java b/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebRecorder.java index 3f209911..1470605f 100644 --- a/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebRecorder.java +++ b/runtime/src/main/java/io/quarkiverse/qute/web/runtime/QuteWebRecorder.java @@ -1,5 +1,6 @@ package io.quarkiverse.qute.web.runtime; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; @@ -32,7 +33,8 @@ public void accept(Route r) { }; } - public Handler handler(String rootPath, Set templatePaths) { - return new QuteWebHandler(rootPath, quteWebConfig.publicDir(), templatePaths, httpConfig); + public Handler handler(String rootPath, + Set templatePaths, Map templateLinks) { + return new QuteWebHandler(rootPath, quteWebConfig.publicDir(), templatePaths, templateLinks, httpConfig); } }