diff --git a/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/RestPaths.java b/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/RestPaths.java index 41c60e2..0a3f6aa 100644 --- a/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/RestPaths.java +++ b/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/RestPaths.java @@ -15,7 +15,7 @@ import org.sitenetsoft.quarkus.seo.runtime.SeoPaths; /** - * Find the prefix that Quarkus REST puts in front of every JAX-RS {@code @Path}. + * Find the prefix that Quarkus REST or RESTEasy Classic puts in front of every JAX-RS {@code @Path}. * *

* The class reads the annotation by name, so the deployment module needs no REST dependency. @@ -27,16 +27,26 @@ final class RestPaths { private RestPaths() { } + /** + * Return the configuration key that holds the REST path of the REST stack in use. + * + * @param resteasyClassic true when RESTEasy Classic is the REST stack + * @return {@code quarkus.resteasy.path} for RESTEasy Classic, {@code quarkus.rest.path} otherwise + */ + static String restPathKey(boolean resteasyClassic) { + return resteasyClassic ? "quarkus.resteasy.path" : "quarkus.rest.path"; + } + /** * Return the JAX-RS prefix of the application. * *

* An {@code @ApplicationPath} on an {@code Application} subclass wins, as in Quarkus REST. Without one, the - * value of {@code quarkus.rest.path} applies. When several classes carry the annotation, the class whose name - * sorts first wins. + * value of the REST path key applies. See {@link #restPathKey(boolean)}. When several classes carry the annotation, + * the class whose name sorts first wins. * * @param index the combined index of the application - * @param restPath the value of {@code quarkus.rest.path}, empty when the key is not set + * @param restPath the value of the REST path key, empty when the key is not set * @return the prefix with one leading slash and no trailing slash, or an empty string for the root */ static String prefix(IndexView index, Optional restPath) { diff --git a/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/SeoProcessor.java b/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/SeoProcessor.java index 651e606..a73f609 100644 --- a/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/SeoProcessor.java +++ b/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/SeoProcessor.java @@ -61,11 +61,7 @@ class SeoProcessor { static final String FEATURE = "seo"; private static final String CACHE_MANAGER_CLASS = "org.sitenetsoft.quarkus.seo.runtime.CacheManagerSeoOutputCache"; - private static final String ROBOTS_PATH = "/robots.txt"; - static final String SECURITY_TXT_PATH = "/.well-known/security.txt"; - static final String LLMS_TXT_PATH = "/llms.txt"; private static final Pattern INDEXNOW_KEY = Pattern.compile("[a-zA-Z0-9-]{8,128}"); - private static final String REST_PATH = "quarkus.rest.path"; private static final String PAGE_TOKEN = "{n}"; private static final String PAGE_GROUP = "([1-9][0-9]*)"; @@ -86,19 +82,20 @@ FeatureBuildItem feature() { * An output is off when the master switch or its feature switch is off. An output is also off when the * application serves its path itself, as a static file, a JAX-RS resource or a Reactive Route. The step checks the * sitemap, robots.txt, security.txt, the manifest, the IndexNow key file and llms.txt. A JAX-RS path includes the - * {@code @ApplicationPath} or {@code quarkus.rest.path} prefix. The step treats every match on a host-root path + * {@code @ApplicationPath} or REST path prefix. The step treats every match on a host-root path * as owned, whatever the root path. The host-root paths are {@code /robots.txt}, {@code /.well-known/security.txt}, * the IndexNow key file and {@code /llms.txt}. The step logs nothing about owned paths. * * @param config the build-time configuration * @param index the combined index + * @param capabilities the capabilities of the application * @param statics the static resources, absent when the application has none * @return the outputs * @throws IllegalStateException when a sitemap or manifest path holds a colon, an asterisk or whitespace, or when * IndexNow is on and its key is missing or invalid */ @BuildStep - SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index, + SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index, Capabilities capabilities, Optional statics) { if (!config.enabled()) { return new SeoOutputsBuildItem(false, false, false, false, false, false, false, null, Set.of()); @@ -113,15 +110,15 @@ SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index, String indexNowKeyPath = null; if (config.indexNow().enabled()) { checkIndexNowKey(config.indexNow().key().orElse(null)); - indexNowKeyPath = "/" + config.indexNow().key().get() + ".txt"; + indexNowKeyPath = SeoPaths.indexNowKey(config.indexNow().key().get()); } String sitemapPath = SeoPaths.normalize(config.sitemap().path()); String manifestPath = SeoPaths.normalize(config.manifest().path()); - Set appPaths = OwnedPaths.fromIndex(index.getIndex(), jaxrsPrefix(index)); + Set appPaths = OwnedPaths.fromIndex(index.getIndex(), jaxrsPrefix(index, capabilities)); Set owned = new LinkedHashSet<>(); - List candidates = new ArrayList<>(List.of(sitemapPath, ROBOTS_PATH)); + List candidates = new ArrayList<>(List.of(sitemapPath, SeoPaths.ROBOTS_TXT)); if (config.securityTxt().enabled()) { - candidates.add(SECURITY_TXT_PATH); + candidates.add(SeoPaths.SECURITY_TXT); } if (config.manifest().enabled()) { candidates.add(manifestPath); @@ -130,7 +127,7 @@ SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index, candidates.add(indexNowKeyPath); } if (config.llmsTxt().enabled()) { - candidates.add(LLMS_TXT_PATH); + candidates.add(SeoPaths.LLMS_TXT); } for (String path : candidates) { if (appPaths.contains(path) || OwnedPaths.ownedStatic(statics, path)) { @@ -138,11 +135,11 @@ SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index, } } boolean sitemap = config.sitemap().enabled() && !owned.contains(sitemapPath); - boolean robots = config.robots().enabled() && !owned.contains(ROBOTS_PATH); - boolean securityTxt = config.securityTxt().enabled() && !owned.contains(SECURITY_TXT_PATH); + boolean robots = config.robots().enabled() && !owned.contains(SeoPaths.ROBOTS_TXT); + boolean securityTxt = config.securityTxt().enabled() && !owned.contains(SeoPaths.SECURITY_TXT); boolean manifest = config.manifest().enabled() && !owned.contains(manifestPath); boolean indexNow = config.indexNow().enabled() && !owned.contains(indexNowKeyPath); - boolean llmsTxt = config.llmsTxt().enabled() && !owned.contains(LLMS_TXT_PATH); + boolean llmsTxt = config.llmsTxt().enabled() && !owned.contains(SeoPaths.LLMS_TXT); return new SeoOutputsBuildItem(config.meta().enabled(), sitemap, robots, securityTxt, manifest, indexNow, llmsTxt, indexNowKeyPath, owned); } @@ -285,15 +282,17 @@ AdditionalBeanBuildItem sitemapBeans(SeoOutputsBuildItem outputs) { * * @param outputs the outputs that the extension registers * @param index the combined index + * @param capabilities the capabilities of the application * @param entries the producer of the entries */ @BuildStep - void sitemapEntries(SeoOutputsBuildItem outputs, CombinedIndexBuildItem index, + void sitemapEntries(SeoOutputsBuildItem outputs, CombinedIndexBuildItem index, Capabilities capabilities, BuildProducer entries) { if (!outputs.sitemap() && !outputs.llmsTxt()) { return; } - for (SitemapEntryBuildItem entry : SitemapEntryScanner.scan(index.getIndex(), jaxrsPrefix(index))) { + String prefix = jaxrsPrefix(index, capabilities); + for (SitemapEntryBuildItem entry : SitemapEntryScanner.scan(index.getIndex(), prefix)) { entries.produce(entry); } } @@ -447,7 +446,8 @@ void robotsRoute(SeoRecorder recorder, SeoBuildConfig config, SeoOutputsBuildIte if (!outputs.robots()) { return; } - routes.produce(hostRootRoute(ROBOTS_PATH, recorder.robotsHandler(), config.robots().serveUnderRootPath(), + routes.produce(hostRootRoute(SeoPaths.ROBOTS_TXT, recorder.robotsHandler(), + config.robots().serveUnderRootPath(), recorder, nonApplicationRootPath)); } @@ -471,7 +471,7 @@ void securityTxtRoute(SeoRecorder recorder, SeoBuildConfig config, SeoOutputsBui return; } boolean underRootPath = config.securityTxt().serveUnderRootPath(); - routes.produce(hostRootRoute(SECURITY_TXT_PATH, recorder.securityTxtHandler(underRootPath), underRootPath, + routes.produce(hostRootRoute(SeoPaths.SECURITY_TXT, recorder.securityTxtHandler(underRootPath), underRootPath, recorder, nonApplicationRootPath)); } @@ -494,7 +494,8 @@ void llmsTxtRoute(SeoRecorder recorder, SeoBuildConfig config, SeoOutputsBuildIt if (!outputs.llmsTxt()) { return; } - routes.produce(hostRootRoute(LLMS_TXT_PATH, recorder.llmsTxtHandler(), config.llmsTxt().serveUnderRootPath(), + routes.produce(hostRootRoute(SeoPaths.LLMS_TXT, recorder.llmsTxtHandler(), + config.llmsTxt().serveUnderRootPath(), recorder, nonApplicationRootPath)); } @@ -652,10 +653,12 @@ static void checkPath(String key, String value) { * Return the JAX-RS prefix of the application. * * @param index the combined index + * @param capabilities the capabilities of the application * @return the prefix, or an empty string for the root */ - private static String jaxrsPrefix(CombinedIndexBuildItem index) { - Optional restPath = ConfigProvider.getConfig().getOptionalValue(REST_PATH, String.class); + private static String jaxrsPrefix(CombinedIndexBuildItem index, Capabilities capabilities) { + String key = RestPaths.restPathKey(capabilities.isPresent(Capability.RESTEASY)); + Optional restPath = ConfigProvider.getConfig().getOptionalValue(key, String.class); return RestPaths.prefix(index.getIndex(), restPath); } diff --git a/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/devui/SeoDevUiProcessor.java b/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/devui/SeoDevUiProcessor.java index be44cc1..8d3f293 100644 --- a/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/devui/SeoDevUiProcessor.java +++ b/deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/devui/SeoDevUiProcessor.java @@ -33,10 +33,6 @@ */ @BuildSteps(onlyIf = IsLocalDevelopment.class) public class SeoDevUiProcessor { - - private static final String ROBOTS_PATH = "/robots.txt"; - private static final String SECURITY_TXT_PATH = "/.well-known/security.txt"; - private static final String LLMS_TXT_PATH = "/llms.txt"; private static final String EXTENSION = "extension"; private static final String APPLICATION = "application"; private static final String OFF = "off"; @@ -162,21 +158,23 @@ private static List> outputRows(SeoBuildConfig config, SeoOu .getOptionalValue("quarkus.http.root-path", String.class).orElse("/")); String sitemapPath = SeoPaths.normalize(config.sitemap().path()); String manifestPath = SeoPaths.normalize(config.manifest().path()); - String indexNowPath = config.indexNow().key().isPresent() ? "/" + config.indexNow().key().get() + ".txt" : ""; + String indexNowPath = config.indexNow().key().isPresent() + ? SeoPaths.indexNowKey(config.indexNow().key().get()) + : ""; List> rows = new ArrayList<>(); rows.add(row("meta", "", outputs.meta() ? EXTENSION : OFF)); rows.add(row("sitemap", rootPath + sitemapPath, servedBy(outputs.sitemap(), outputs, sitemapPath))); - rows.add(row("robots.txt", hostRoot(rootPath, config.robots().serveUnderRootPath()) + ROBOTS_PATH, - servedBy(outputs.robots(), outputs, ROBOTS_PATH))); + rows.add(row("robots.txt", hostRoot(rootPath, config.robots().serveUnderRootPath()) + SeoPaths.ROBOTS_TXT, + servedBy(outputs.robots(), outputs, SeoPaths.ROBOTS_TXT))); rows.add(row("security.txt", - hostRoot(rootPath, config.securityTxt().serveUnderRootPath()) + SECURITY_TXT_PATH, - servedBy(outputs.securityTxt(), outputs, SECURITY_TXT_PATH))); + hostRoot(rootPath, config.securityTxt().serveUnderRootPath()) + SeoPaths.SECURITY_TXT, + servedBy(outputs.securityTxt(), outputs, SeoPaths.SECURITY_TXT))); rows.add(row("manifest", rootPath + manifestPath, servedBy(outputs.manifest(), outputs, manifestPath))); rows.add(row("IndexNow key", indexNowPath.isEmpty() ? "" : hostRoot(rootPath, config.indexNow().serveUnderRootPath()) + indexNowPath, indexNowPath.isEmpty() ? OFF : servedBy(outputs.indexNow(), outputs, indexNowPath))); - rows.add(row("llms.txt", hostRoot(rootPath, config.llmsTxt().serveUnderRootPath()) + LLMS_TXT_PATH, - servedBy(outputs.llmsTxt(), outputs, LLMS_TXT_PATH))); + rows.add(row("llms.txt", hostRoot(rootPath, config.llmsTxt().serveUnderRootPath()) + SeoPaths.LLMS_TXT, + servedBy(outputs.llmsTxt(), outputs, SeoPaths.LLMS_TXT))); return rows; } diff --git a/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/IndexNowKeyUnderRootPathTest.java b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/IndexNowKeyUnderRootPathTest.java new file mode 100644 index 0000000..dae1b71 --- /dev/null +++ b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/IndexNowKeyUnderRootPathTest.java @@ -0,0 +1,28 @@ +package org.sitenetsoft.quarkus.seo.deployment; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +class IndexNowKeyUnderRootPathTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .overrideConfigKey("quarkus.seo.indexable", "true") + .overrideConfigKey("quarkus.http.root-path", "/app") + .overrideConfigKey("quarkus.seo.indexnow.enabled", "true") + .overrideConfigKey("quarkus.seo.indexnow.key", "abcdef0123456789") + .overrideConfigKey("quarkus.seo.indexnow.serve-under-root-path", "true"); + + @Test + void keyFileMovesUnderTheRootPath() { + String body = given().basePath("").get("/app/abcdef0123456789.txt").then().statusCode(200).extract() + .asString(); + assertThat(body).isEqualTo("abcdef0123456789"); + given().basePath("").get("/abcdef0123456789.txt").then().statusCode(404); + } +} diff --git a/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/LeftoverResteasyPathTest.java b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/LeftoverResteasyPathTest.java new file mode 100644 index 0000000..400f352 --- /dev/null +++ b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/LeftoverResteasyPathTest.java @@ -0,0 +1,43 @@ +package org.sitenetsoft.quarkus.seo.deployment; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; +import org.sitenetsoft.quarkus.seo.SitemapEntry; + +class LeftoverResteasyPathTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .overrideConfigKey("quarkus.resteasy.path", "/legacy") + .overrideConfigKey("quarkus.seo.indexable", "true") + .withApplicationRoot(jar -> jar.addClasses(Products.class)); + + @Test + void leftoverResteasyPathDoesNotPrefixSitemapEntries() { + String base = "http://localhost:" + RestAssured.port; + String body = given().basePath("").get("/sitemap.xml").then().statusCode(200).extract().asString(); + assertThat(body).contains("" + base + "/products"); + assertThat(body).doesNotContain("/legacy"); + } + + @Path("/products") + public static class Products { + + @GET + @Produces("text/plain") + @SitemapEntry + public String list() { + return "products"; + } + } +} diff --git a/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/LlmsTxtUnderRootPathTest.java b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/LlmsTxtUnderRootPathTest.java new file mode 100644 index 0000000..8ac4b8e --- /dev/null +++ b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/LlmsTxtUnderRootPathTest.java @@ -0,0 +1,40 @@ +package org.sitenetsoft.quarkus.seo.deployment; + +import static io.restassured.RestAssured.given; + +import java.util.stream.Stream; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import org.sitenetsoft.quarkus.seo.SitemapContributor; +import org.sitenetsoft.quarkus.seo.SitemapUrl; + +class LlmsTxtUnderRootPathTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .overrideConfigKey("quarkus.http.root-path", "/app") + .overrideConfigKey("quarkus.seo.indexable", "true") + .overrideConfigKey("quarkus.seo.llms-txt.enabled", "true") + .overrideConfigKey("quarkus.seo.llms-txt.serve-under-root-path", "true") + .withApplicationRoot(jar -> jar.addClasses(Pages.class)); + + @Test + void llmsTxtMovesUnderTheRootPath() { + given().basePath("").get("/app/llms.txt").then().statusCode(200); + given().basePath("").get("/llms.txt").then().statusCode(404); + } + + @ApplicationScoped + public static class Pages implements SitemapContributor { + + @Override + public Stream urls() { + return Stream.of(SitemapUrl.of("/a").title("A page")); + } + } +} diff --git a/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/RestPathsTest.java b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/RestPathsTest.java new file mode 100644 index 0000000..cc6b127 --- /dev/null +++ b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/RestPathsTest.java @@ -0,0 +1,61 @@ +package org.sitenetsoft.quarkus.seo.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.util.Optional; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +import org.jboss.jandex.Index; +import org.jboss.jandex.Indexer; +import org.junit.jupiter.api.Test; + +class RestPathsTest { + + @ApplicationPath("/v1") + static class V1 extends Application { + } + + @ApplicationPath("/b") + static class B extends Application { + } + + @ApplicationPath("/a") + static class A extends Application { + } + + private static Index index(Class... classes) throws IOException { + Indexer indexer = new Indexer(); + for (Class type : classes) { + indexer.indexClass(type); + } + return indexer.complete(); + } + + @Test + void classicReadsTheResteasyKey() { + assertEquals("quarkus.resteasy.path", RestPaths.restPathKey(true)); + } + + @Test + void restReadsTheRestKey() { + assertEquals("quarkus.rest.path", RestPaths.restPathKey(false)); + } + + @Test + void applicationPathWins() throws IOException { + assertEquals("/v1", RestPaths.prefix(index(V1.class), Optional.of("/api"))); + } + + @Test + void keyApplies() throws IOException { + assertEquals("/api", RestPaths.prefix(index(), Optional.of("/api/"))); + } + + @Test + void firstSortedClassWins() throws IOException { + assertEquals("/a", RestPaths.prefix(index(B.class, A.class), Optional.empty())); + } +} diff --git a/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/SecurityTxtUnderRootPathTest.java b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/SecurityTxtUnderRootPathTest.java new file mode 100644 index 0000000..360f517 --- /dev/null +++ b/deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/SecurityTxtUnderRootPathTest.java @@ -0,0 +1,28 @@ +package org.sitenetsoft.quarkus.seo.deployment; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +class SecurityTxtUnderRootPathTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .overrideConfigKey("quarkus.seo.indexable", "true") + .overrideConfigKey("quarkus.http.root-path", "/app") + .overrideConfigKey("quarkus.seo.security-txt.enabled", "true") + .overrideConfigKey("quarkus.seo.security-txt.contact", "mailto:sec@ex.com") + .overrideConfigKey("quarkus.seo.security-txt.serve-under-root-path", "true"); + + @Test + void securityTxtMovesUnderTheRootPath() { + String body = given().basePath("").get("/app/.well-known/security.txt").then().statusCode(200).extract() + .asString(); + assertThat(body).contains("Canonical: http://localhost:").endsWith("/app/.well-known/security.txt\n"); + given().basePath("").get("/.well-known/security.txt").then().statusCode(404); + } +} diff --git a/docs/design/plans/2026-11-08-quarkus-seo-hardening.md b/docs/design/plans/2026-11-08-quarkus-seo-hardening.md new file mode 100644 index 0000000..07ed5b6 --- /dev/null +++ b/docs/design/plans/2026-11-08-quarkus-seo-hardening.md @@ -0,0 +1,229 @@ +# Quarkus SEO Hardening for 0.1.0 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the known gaps that plans 1 and 2 left, so that `quarkus-seo` can ship as 0.1.0 with a public API it can keep. + +**Architecture:** No new features. Each task fixes one gap listed in PR #1 or PR #2: the public API surface, the IndexNow client lifecycle, the image merge group, the RESTEasy Classic path, untested host-root routes, the Dev UI log set, and stale docs. The spec is changed only where a task changes a rule it states (§5.2 image group, §7.1 REST prefix). + +**Tech Stack:** Java 21 bytecode on JDK 25, Maven 3.9, Quarkus 3.40.1, JUnit 5, AssertJ, REST Assured, Jandex. + +**Spec:** `docs/design/specs/2026-10-29-quarkus-seo-design.md`, mainly §5.2, §7.1, §8.1, §9 and §12. + +**Base:** Plan 2 is on branch `seo-opt-in` (PR #2, based on `seo-core`, PR #1). Start this plan on a new branch `seo-hardening` from `seo-opt-in`. Rebase onto `master` after PRs #1 and #2 merge. + +## Global Constraints + +- Every constraint in plan 1 (`docs/design/plans/2026-11-02-quarkus-seo-core.md`) and plan 2 (`docs/design/plans/2026-11-03-quarkus-seo-opt-in-and-dev-ui.md`) "Global Constraints" still applies: versions, packages, prose and Javadoc rules, commit messages (one imperative sentence, no trailers), no Jackson, no `beans.xml`, quiet prod. +- Full build: `JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 mvn -B -ntp -T 4 verify`. Focused run: add `-pl runtime` or `-pl deployment -am`, plus `-Dsurefire.failIfNoSpecifiedTests=false -Dtest=`. +- Host build rule: run only one heavy job at a time. Before any Maven run, check `pgrep -a java` and `pgrep -a native-image`. A running app (`quarkus-run.jar`, a Quarkus dev-mode JVM) is not a build. A Maven or native-image process from any session is a build: wait for it to end. Do not write a wait loop with `pgrep -f` on a pattern that the loop's own command line contains, because it matches itself. +- Native builds always pass `-Dquarkus.native.additional-build-args=-J-XX:TieredStopAtLevel=1,--parallelism=8`, appended to any value the project already sets. +- The public API is the packages `org.sitenetsoft.quarkus.seo` and `org.sitenetsoft.quarkus.seo.schema`. Everything under `.runtime` and `.deployment` is internal. `.schema` imports only `java.*`. +- No behaviour change outside the gap a task names. Existing tests keep passing unchanged, except where a task says to change one. + +## Review Focus + +- A page where the tag sets `image` and the Java handler sets only `imageAlt` must keep the tag's image and show the Java alt text. Test in Task 3. +- Dev-mode live reload or shutdown while an IndexNow request waits on a slow endpoint must not block for the 30-second request timeout. Test in Task 2. +- An application on Quarkus REST that still has a leftover `quarkus.resteasy.path` must not get that prefix on its sitemap entries. Test in Task 4. +- security.txt served under the root path must give a `Canonical:` line with the root path, because that is where the file now lives. Test in Task 5. +- After the Dev UI checks a 200-URL sitemap several times, browsing a page that has already logged must not log its warnings again. Test in Task 6. + +--- + +### Task 1: Public API surface + +**Files:** +- Modify: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/schema/JsonWriter.java` (class and `string` back to package-private) +- Create: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/Json.java` +- Modify: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/ManifestWriter.java`, `DefaultIndexNow.java` (use `Json.string`) +- Modify: `runtime/src/test/java/org/sitenetsoft/quarkus/seo/PublicApiTest.java` +- Test: `runtime/src/test/java/org/sitenetsoft/quarkus/seo/schema/JsonEscapingParityTest.java` + +**Interfaces:** +- Produces: `public final class Json` in `.runtime` with `public static void string(StringBuilder out, String value)`. Javadoc says the class is internal. Its output is byte for byte the same as `JsonWriter.string` for every input. +- `JsonWriter` and all its members are package-private again, as plan 1 specified. + +Ruling (user, 2026-11-08): `JsonWriter` is not public API. The cost is one duplicated escape routine of about 20 lines. The parity test keeps the two copies equal. `.schema` cannot call `.runtime`, because `.schema` is JDK-only. + +- [ ] **Step 1: Create the branch.** `git switch -c seo-hardening seo-opt-in`, then commit this plan: `git add docs/design/plans/2026-11-08-quarkus-seo-hardening.md && git commit -m "Add the plan for the 0.1.0 hardening"`. + +- [ ] **Step 2: Write the failing tests.** + - `JsonEscapingParityTest` (in package `org.sitenetsoft.quarkus.seo.schema`, so it can call the package-private `JsonWriter.string`). For each of `""`, `"plain"`, `"a\"b"`, `"back\\slash"`, `""`, `"\u0000\u0001\u001f"`, `"\n\r\t\b\f"`, `"

"`, `"é😀"`, `JsonWriter.string` and `Json.string` give the same text. + - `PublicApiTest`: + - also scans `org.sitenetsoft.quarkus.seo.schema`; + - asserts that the public types in `.schema` are exactly `Article`, `BreadcrumbList`, `JsonLd`, `Organization`, `SchemaNode`, `WebSite`, plus their public nested builder types if any; + - checks inherited members too: for each public type, check `type.getMethods()` (public, inherited included) as well as the declared protected methods. A public method inherited from a non-public class must not expose a `.runtime.` type. + +- [ ] **Step 3: Run them.** `-pl runtime -Dtest='PublicApiTest,JsonEscapingParityTest'`. Expected: the parity test does not compile, because `Json` does not exist. After a stub, `PublicApiTest` fails because `JsonWriter` is public. + +- [ ] **Step 4: Implement.** Copy the escape rules of `JsonWriter.string` into `Json.string`. Switch `ManifestWriter` and `DefaultIndexNow` to `Json`. Make `JsonWriter` package-private and remove the public-API wording from its Javadoc. Leave the existing writer tests unchanged: `ManifestWriterTest` and `DefaultIndexNowTest` must still pass. + +- [ ] **Step 5: Run the full build.** Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit.** `git commit -m "Keep JsonWriter internal and check the schema package in the public API test"` + +--- + +### Task 2: Close the IndexNow HTTP client + +**Files:** +- Modify: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNow.java` +- Test: `runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNowTest.java` + +**Interfaces:** +- Produces: `@PreDestroy void close()` on `DefaultIndexNow`. It calls `client.shutdownNow()`, which is JDK 21 API. It must not throw. + +Ruling: use `shutdownNow()`, not `close()`. `HttpClient.close()` waits for in-flight requests, and a slow endpoint would hold up a live reload for up to the 30-second timeout. A submission that shutdown cuts short is lost, just as it would be if the endpoint failed, and it is logged at DEBUG. If this is wrong, the cost is a lost ping at shutdown. + +- [ ] **Step 1: Write the failing tests** in `DefaultIndexNowTest`, with a JDK `HttpServer` on port 0, as the existing tests use: + - `closeDoesNotWaitForASlowEndpoint`: the handler sleeps 10 seconds. Call `submit("/a")`, then `close()`. `close()` returns within 2 seconds, and the stage completes normally within 2 seconds after that (`toCompletableFuture().get(2, SECONDS)` does not throw). + - `submitAfterCloseCompletesNormally`: after `close()`, `submit("/a")` returns a stage that completes normally, and the server gets no request. + +- [ ] **Step 2: Run them.** `-pl runtime -Dtest=DefaultIndexNowTest`. Expected: FAIL, because `close` does not exist. + +- [ ] **Step 3: Implement `close()`.** The existing `RuntimeException` guards in `submit` and `post` already turn a rejected `sendAsync` into a completed stage. Confirm this with the second test. Do not add code for it. + +- [ ] **Step 4: Run the tests, then the full build.** Expected: PASS, then BUILD SUCCESS. `IndexNowSubmitTest`, `IndexNowFailureTest` and `IndexNowUnreachableTest` still pass. + +- [ ] **Step 5: Commit.** `git commit -m "Shut down the IndexNow client when the bean is destroyed"` + +--- + +### Task 3: Size or alt text alone keeps the lower image + +**Files:** +- Modify: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoValues.java` (`copyFrom`) +- Modify: `docs/design/specs/2026-10-29-quarkus-seo-design.md` §5.2, `docs/modules/ROOT/pages/meta-tags.adoc` (the merge-order section) +- Test: `runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoValuesTest.java`, `SeoResolverTest.java` + +**Interfaces:** +- No signature changes. + +Ruling: the image group is keyed on `image`. A layer that sets `image` replaces the whole group: image, width, height and alt, and the fields it leaves unset become empty. A layer that sets only width, height or alt keeps the lower image and replaces just the fields it sets. The title group stays as it is. This changes the §5.2 sentence "A layer that sets any field of a group replaces the whole group." A reasonable user who sets only `imageAlt` in Java expects to keep the tag's image. Under the current rule the image disappears. If this is wrong, the cost is a stale width or height kept from a lower image. That only happens when a layer sets a size without the image it belongs to. + +- [ ] **Step 1: Write the failing tests.** + - `SeoValuesTest.altAloneKeepsTheLowerImage`: lower is `image("/a.png").imageWidth(100).imageAlt("A")`, upper is `imageAlt("B")`. The merged values have image `/a.png`, width `100` and alt `B`. + - `SeoValuesTest.sizeAloneKeepsTheLowerImage`: lower is `image("/a.png").imageAlt("A")`, upper is `imageWidth(200).imageHeight(100)`. The merged values have image `/a.png`, width `200`, height `100` and alt `A`. + - `SeoValuesTest.imageReplacesTheWholeGroup`: lower is `image("/a.png").imageWidth(100).imageAlt("A")`, upper is `image("/b.png")`. The merged values have image `/b.png`, and width and alt are null. This test should already pass, so it guards the rule. + - `SeoResolverTest.javaAltKeepsTheTagImage`: the tag sets `image=/t.png` and Java sets only `imageAlt("Java alt")`. The resolved image URL ends with `/t.png`, and its alt is `Java alt`. + +- [ ] **Step 2: Run them.** `-pl runtime -Dtest='SeoValuesTest,SeoResolverTest'`. Expected: the two "alone" tests and `javaAltKeepsTheTagImage` fail. + +- [ ] **Step 3: Implement.** In `copyFrom`: + - When `source.image != null`, copy all four fields. + - Otherwise, copy each of `imageWidth`, `imageHeight` and `imageAlt` that is non-null. + +- [ ] **Step 4: Update the spec and docs.** Spec §5.2: replace the image half of the group rule with the ruling above, in one or two plain sentences. `meta-tags.adoc`: state the same rule, with a one-line example (tag image, Java alt). + +- [ ] **Step 5: Run the full build.** Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit.** `git commit -m "Keep the lower image when a layer sets only its size or alt text"` + +--- + +### Task 4: RESTEasy Classic path prefix + +**Files:** +- Modify: `deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/SeoProcessor.java` (where it reads `REST_PATH`, around line 658), `RestPaths.java` +- Modify: `docs/design/specs/2026-10-29-quarkus-seo-design.md` §7.1, `docs/modules/ROOT/pages/sitemap.adoc` (the `@SitemapEntry` section) +- Test: `deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/RestPathsTest.java` (new, plain JUnit, no `QuarkusUnitTest`) + +**Interfaces:** +- Produces: `static String restPathKey(boolean resteasyClassic)` in `RestPaths`. It returns `"quarkus.resteasy.path"` when the argument is true and `"quarkus.rest.path"` otherwise. +- `SeoProcessor` passes `capabilities.isPresent(Capability.RESTEASY)` and reads that one key. `RestPaths.prefix(IndexView, Optional)` keeps its signature. + +Ruling: the extension reads only the key of the REST stack that is present. A leftover `quarkus.resteasy.path` in a Quarkus REST application is ignored, as Quarkus REST ignores it. When several `@ApplicationPath` classes exist, the class whose name sorts first still wins. That order is deterministic, documented in `RestPaths`, and Quarkus itself rejects most such applications. If this is wrong, the cost is a wrong prefix in a Classic application that also has `quarkus.rest.path` set. + +- [ ] **Step 1: Write the failing tests** in `RestPathsTest`: + - `classicReadsTheResteasyKey`: `restPathKey(true)` is `quarkus.resteasy.path`. + - `restReadsTheRestKey`: `restPathKey(false)` is `quarkus.rest.path`. + - `applicationPathWins`: index a test class annotated `@ApplicationPath("/v1")` with Jandex's `Indexer`. `prefix(index, Optional.of("/api"))` is `/v1`. + - `keyApplies`: with an empty index, `prefix(index, Optional.of("/api/"))` is `/api`. + - `firstSortedClassWins`: index two `@ApplicationPath` classes, `B` with `/b` and `A` with `/a`. The result is `/a`. + +- [ ] **Step 2: Run them.** `-pl deployment -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=RestPathsTest`. Expected: does not compile, because `restPathKey` does not exist. + +- [ ] **Step 3: Implement.** Add the `Capabilities` parameter to the build step that reads the key, if it does not have one. The existing `RestPathTest` (Quarkus REST, `quarkus.rest.path=/api`) must pass unchanged. It also covers the Review Focus line: Quarkus REST reads only `quarkus.rest.path`. + +- [ ] **Step 4: Update the spec and docs.** §7.1 and `sitemap.adoc`: under Quarkus REST, the prefix comes from `@ApplicationPath` or `quarkus.rest.path`. Under RESTEasy Classic, it comes from `@ApplicationPath` or `quarkus.resteasy.path`. + +- [ ] **Step 5: Run the full build.** Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit.** `git commit -m "Read the RESTEasy Classic path when Classic is the REST stack"` + +--- + +### Task 5: Host-root files under the root path, and shared path constants + +**Files:** +- Modify: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoPaths.java` (add the constants) +- Modify: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SecurityTxtHandler.java`, `deployment/src/main/java/org/sitenetsoft/quarkus/seo/deployment/SeoProcessor.java`, `deployment/.../devui/SeoDevUiProcessor.java` (use the constants) +- Test: `deployment/src/test/java/org/sitenetsoft/quarkus/seo/deployment/SecurityTxtUnderRootPathTest.java`, `LlmsTxtUnderRootPathTest.java`, `IndexNowKeyUnderRootPathTest.java` + +**Interfaces:** +- Produces in `SeoPaths`: `public static final String ROBOTS_TXT = "/robots.txt"`, `SECURITY_TXT = "/.well-known/security.txt"` and `LLMS_TXT = "/llms.txt"`. +- Remove the private copies: `ROBOTS_PATH`, `SECURITY_TXT_PATH` and `LLMS_TXT_PATH` in both processors, and `PATH` in `SecurityTxtHandler`. Callers use the `SeoPaths` names. + +- [ ] **Step 1: Write the tests.** Model each one on `RobotsUnderRootPathTest`: `quarkus.http.root-path=/app`, the feature's `serve-under-root-path=true`, `quarkus.seo.indexable=true`, and `given().basePath("")`. + - `SecurityTxtUnderRootPathTest`: set `security-txt.enabled=true` and `security-txt.contact=mailto:sec@ex.com`. + - `/app/.well-known/security.txt` answers 200. + - The body contains `Canonical: http://localhost:` and ends with `/app/.well-known/security.txt\n`. + - `/.well-known/security.txt` answers 404. + - `LlmsTxtUnderRootPathTest`: with `llms-txt.enabled=true`, `/app/llms.txt` answers 200 and `/llms.txt` answers 404. + - `IndexNowKeyUnderRootPathTest`: with `indexnow.enabled=true` and `indexnow.key=abcdef0123456789`, `/app/abcdef0123456789.txt` answers 200 with body `abcdef0123456789`, and `/abcdef0123456789.txt` answers 404. + +- [ ] **Step 2: Run them.** `-pl deployment -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest='*UnderRootPathTest'`. Expected: PASS. These tests pin behaviour that already exists. If one fails, the route rule has a bug: fix it in `hostRootRoute` and say so in the report. + +- [ ] **Step 3: Replace the duplicated constants** with the `SeoPaths` names. + +- [ ] **Step 4: Run the full build.** Expected: BUILD SUCCESS. + +- [ ] **Step 5: Commit.** `git commit -m "Test the host-root files under the root path and share their path constants"` + +--- + +### Task 6: Dev UI log set survives sitemap checks + +**Files:** +- Modify: `runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCapture.java` +- Test: `runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCaptureTest.java` (new) + +**Interfaces:** +- Produces: `boolean logged(String path)`, package-private, for tests. It returns true when the path is in the logged set. +- Constants: `MAX_PATHS = 1000` stays for `captured`. A new `MAX_LOGGED = 10_000` bounds `logged`. + +Ruling: raise the bound of the logged set, and keep the bound of the captured heads. A logged entry is one key and costs little. A captured `ResolvedSeo` costs more. With 10,000 entries, fifty 200-URL sitemap checks still keep a browsed page's entry. If this is wrong, a page logs its warnings a second time after a very long dev session. That is the same symptom as today, much later. + +- [ ] **Step 1: Write the failing tests** in `SeoDevCaptureTest`. Use a minimal `ResolvedSeo`. Copy the fixture from `SeoChecksTest` if it has one. + - `browsedPathStaysLoggedAfterManyExpects`: `record("/a", seo)`, then `expect("/p" + i)` for i from 0 to 1,499. `logged("/a")` is true. + - `expectedPathDoesNotLogLater`: `expect("/b")`, then `record("/b", seo)`. `logged("/b")` is true, and `last("/b")` is present. + - `capturedHeadsStayBounded`: record 1,001 paths. `last` of the first path is empty. + +- [ ] **Step 2: Run them.** `-pl runtime -Dtest=SeoDevCaptureTest`. Expected: the first test fails. + +- [ ] **Step 3: Implement.** Give `logged` its own bound. Rewrite the `expect` Javadoc to say what it does: it marks the path as logged for the rest of the dev session, so neither the Dev UI fetch nor later browsing logs that path's warnings. The Dev UI shows them instead. Update the class Javadoc to give both bounds. + +- [ ] **Step 4: Run the full build.** Expected: BUILD SUCCESS. `DevCaptureLogTest` and `PageCheckLogTest` still pass. + +- [ ] **Step 5: Commit.** `git commit -m "Bound the Dev UI log set separately so sitemap checks do not evict browsed pages"` + +--- + +### Task 7: Docs, spec status and release check + +**Files:** +- Modify: `docs/modules/ROOT/pages/sitemap.adoc` (alternates bullet), `docs/modules/ROOT/pages/dev-ui.adoc`, `docs/design/specs/2026-10-29-quarkus-seo-design.md` (status line) +- No code. + +- [ ] **Step 1: Sitemap self link.** In the alternates bullet of `sitemap.adoc`, add one sentence: the extension skips the extra self link when an alternate already uses the default locale. This matches `DefaultSitemapWriter.writeAlternates`. + +- [ ] **Step 2: Dev UI log rule.** In `dev-ui.adoc`, state the rule in one or two sentences. A page check marks the page as shown, and the console does not log that page's warnings later in the session. + +- [ ] **Step 3: Spec status.** Change the status line to `implemented (plans 1 to 3)`. + +- [ ] **Step 4: Release script test.** Run `bin/test-release-scripts.sh`. Expected: it passes. Do not change the version, tag or deploy. The release is the user's step, done by following `releasing.adoc`. + +- [ ] **Step 5: Native IT.** Check the host build rule first. Then run `JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 mvn -B -ntp -T 4 verify -Dnative -Dquarkus.native.container-build=true -Dquarkus.native.additional-build-args=-J-XX:TieredStopAtLevel=1,--parallelism=8 -pl integration-tests -am`. Expected: the native IT passes 13/13, as in plan 2. If a JVM or native-image process crashes, run `journalctl -k | grep -i mce` before you debug. + +- [ ] **Step 6: Commit.** `git commit -m "Document the sitemap self link and the Dev UI log rule and mark the spec implemented"` diff --git a/docs/design/specs/2026-10-29-quarkus-seo-design.md b/docs/design/specs/2026-10-29-quarkus-seo-design.md index b21d109..0bbe281 100644 --- a/docs/design/specs/2026-10-29-quarkus-seo-design.md +++ b/docs/design/specs/2026-10-29-quarkus-seo-design.md @@ -1,6 +1,6 @@ # Quarkus SEO Design -**Status:** implemented (plans 1 and 2) +**Status:** implemented (plans 1 to 3) **Date:** 2026-10-29 **Deliverable:** a Quarkus extension, `org.sitenetsoft:quarkus-seo`, built with Maven. It gives an application meta tags, JSON-LD, a sitemap and robots.txt with almost no setup. It also offers @@ -246,8 +246,10 @@ the designer's call. A higher layer replaces only the fields it sets. Setting a title does not clear the image. Lists such as alternates and JSON-LD nodes add to the lower layer. They do not replace it. -Some fields merge as a group. The title and the absolute title are one group. The image, its size and its alt text -are another. A layer that sets any field of a group replaces the whole group of the lower layers. +Some fields merge as a group. The title and the absolute title are one group. A layer that sets any field of this +group replaces the whole group of the lower layers. The image, its size and its alt text are another group, keyed on +the image. A layer that sets the image replaces the whole group, and the fields it leaves unset become empty. A layer +that sets only the width, the height or the alt text keeps the lower image and replaces just the fields it sets. ### 5.3 Computed values @@ -345,6 +347,11 @@ The sitemap combines three sources: `Stream urls()`. The stream is read on a worker thread and closed after use, so a contributor can stream from a database cursor. +The path of an annotated method includes the JAX-RS prefix. Under Quarkus REST, the prefix comes +from `@ApplicationPath` or `quarkus.rest.path`. Under RESTEasy Classic, it comes from +`@ApplicationPath` or `quarkus.resteasy.path`. The extension reads only the key of the REST stack +that is present. + `SitemapUrl` holds: - `loc` (relative or absolute) - `lastmod` (optional) @@ -495,7 +502,7 @@ All of these are off by default. Each one has a build-time `enabled` key. no route, bean or filter. - **Application files win.** At build time the extension checks for its paths among: - static resources (`META-INF/resources/robots.txt` and the others) - - JAX-RS resource paths, with the `@ApplicationPath` or `quarkus.rest.path` prefix + - JAX-RS resource paths, with the `@ApplicationPath` prefix or the REST path key of the stack in use - Reactive Routes paths If the application has any of them, the extension does not register its own route for that diff --git a/docs/modules/ROOT/pages/configuration.adoc b/docs/modules/ROOT/pages/configuration.adoc index 57ab62d..c30aecc 100644 --- a/docs/modules/ROOT/pages/configuration.adoc +++ b/docs/modules/ROOT/pages/configuration.adoc @@ -36,7 +36,8 @@ The extension uses these Quarkus keys as they are: * `quarkus.http.root-path` is the prefix of endpoints and generated links. The extension does not append it to `quarkus.seo.site-url`. The site URL is the public URL of the application root, so it must hold any public path prefix itself. -* `quarkus.rest.path` and an `@ApplicationPath` annotation are the prefix of the `@SitemapEntry` paths. +* An `@ApplicationPath` annotation is the prefix of the `@SitemapEntry` paths. Without one, +`quarkus.rest.path` sets it under Quarkus REST and `quarkus.resteasy.path` under RESTEasy Classic. * `quarkus.http.proxy.*` controls which forwarded headers Quarkus trusts. * `quarkus.http.enable-compression` controls gzip of the sitemap. * `quarkus.cache.*` configures the caches `seo-sitemap` and `seo-robots`. diff --git a/docs/modules/ROOT/pages/dev-ui.adoc b/docs/modules/ROOT/pages/dev-ui.adoc index 0a17f9e..7976b57 100644 --- a/docs/modules/ROOT/pages/dev-ui.adoc +++ b/docs/modules/ROOT/pages/dev-ui.adoc @@ -105,5 +105,6 @@ SEO check on /about: The page has no description. [missing-description] At start, the extension logs the security.txt warnings the same way. It does so only when it serves a security.txt file that has at least one contact. The log lines appear in dev mode only. -The page check and the sitemap check do not log. They show their warnings in the Dev UI. A path that the Dev UI -fetched does not log its warnings when you open it in the browser later. +The page check and the sitemap check do not log. They show their warnings in the Dev UI. Each check marks every page +it fetches, hreflang alternates included, as logged. The console does not log those pages' warnings again until the +next live reload, not even when you open a page in the browser. The Dev UI shows them instead. diff --git a/docs/modules/ROOT/pages/meta-tags.adoc b/docs/modules/ROOT/pages/meta-tags.adoc index 9f21c7e..224bd05 100644 --- a/docs/modules/ROOT/pages/meta-tags.adoc +++ b/docs/modules/ROOT/pages/meta-tags.adoc @@ -21,11 +21,16 @@ as not set, on the tag and in Java. A higher layer replaces only the fields that it sets. Setting a title does not clear the image. Lists such as alternates and JSON-LD nodes add to the lower layer. -Some fields form a group. The title and the absolute title are one group. The image, its width, its height and its -alt text are another group. When a higher layer sets any field of a group, the whole group comes from that layer. A -field of the group that the higher layer does not set stays unset. For example, an image from Java drops the width, -the height and the alt text of the image in the tag. The key `quarkus.seo.image-alt` applies only to the image of -`quarkus.seo.image`. +Some fields form a group. The title and the absolute title are one group. When a higher layer sets either, the whole +group comes from that layer. + +The image, its width, its height and its alt text are another group, keyed on the image. When a higher layer sets the +image, the whole group comes from that layer. A field that the layer does not set stays unset. For example, an image +from Java drops the width, the height and the alt text of the image in the tag. When a higher layer sets only the +width, the height or the alt text, it keeps the image of the lower layer and replaces just those fields. For example, +`imageAlt("Java alt")` in Java keeps the image of the tag and changes its alt text. + +The key `quarkus.seo.image-alt` applies only to the image of `quarkus.seo.image`. === Set values in the template diff --git a/docs/modules/ROOT/pages/opt-in.adoc b/docs/modules/ROOT/pages/opt-in.adoc index 9271cb1..aa7272b 100644 --- a/docs/modules/ROOT/pages/opt-in.adoc +++ b/docs/modules/ROOT/pages/opt-in.adoc @@ -42,7 +42,7 @@ xref:robots.adoc#when-your-application-serves-robotstxt[robots.txt]. The checked the root path. The `@SitemapEntry` paths and the ownership check include the JAX-RS prefix. An `@ApplicationPath` sets that prefix. -Without one, `quarkus.rest.path` sets it. +Without one, `quarkus.rest.path` sets it under Quarkus REST and `quarkus.resteasy.path` under RESTEasy Classic. == security.txt diff --git a/docs/modules/ROOT/pages/robots.adoc b/docs/modules/ROOT/pages/robots.adoc index 02e98e2..598dbf2 100644 --- a/docs/modules/ROOT/pages/robots.adoc +++ b/docs/modules/ROOT/pages/robots.adoc @@ -68,7 +68,8 @@ If your application serves its own `/robots.txt`, the extension serves none. Thi resource and for a Reactive Route. It holds under a root path other than `/` too. A JAX-RS path includes the JAX-RS prefix. An `@ApplicationPath` on an `Application` subclass sets that prefix. -Without one, `quarkus.rest.path` sets it. With `quarkus.rest.path=/api`, a resource at `@Path("/robots.txt")` +Without one, `quarkus.rest.path` sets it under Quarkus REST and `quarkus.resteasy.path` under RESTEasy Classic. +With `quarkus.rest.path=/api`, a resource at `@Path("/robots.txt")` answers `/api/robots.txt`. The extension then still serves `/robots.txt`. The build checks for the conflict. It does not see these cases: diff --git a/docs/modules/ROOT/pages/sitemap.adoc b/docs/modules/ROOT/pages/sitemap.adoc index cb3b2a1..6a4afa7 100644 --- a/docs/modules/ROOT/pages/sitemap.adoc +++ b/docs/modules/ROOT/pages/sitemap.adoc @@ -31,8 +31,9 @@ public TemplateInstance products() { Only the value `-1` means that `priority` is unset. Any other value must be between 0.0 and 1.0. + The sitemap path of the method includes the JAX-RS prefix. An `@ApplicationPath` on an `Application` subclass sets -that prefix. Without one, the build-time key `quarkus.rest.path` sets it. With `quarkus.rest.path=/api`, the method -above appears in the sitemap as `/api/products`. Reactive Routes take no JAX-RS prefix. +that prefix. Without one, a build-time key sets it: `quarkus.rest.path` under Quarkus REST, +`quarkus.resteasy.path` under RESTEasy Classic. With `quarkus.rest.path=/api`, the method above appears in the +sitemap as `/api/products`. Reactive Routes take no JAX-RS prefix. Beans:: Write a CDI bean that implements `SitemapContributor`. Its `urls()` method returns a `Stream`. The @@ -75,7 +76,8 @@ The default is `/sitemap-{n}.xml`. The token `{n}` is the page number. * The extension adds a leading slash to both paths, collapses repeated slashes and strips a trailing slash. If a path holds a colon, an asterisk or whitespace, the build fails. * Alternates become `xhtml:link` elements, with a link for the page itself and an `x-default` link. If no alternate -points to the URL itself, the extension adds a link for the URL with the tag of the default locale. +points to the URL itself, the extension adds a link for the URL with the tag of the default locale. It skips that +extra link when an alternate already uses the default locale. * The extension writes images with the image namespace. * The content type is `application/xml; charset=utf-8`. * Every response has an ETag. A request with a matching `If-None-Match` header gets a 304 response. @@ -126,7 +128,7 @@ application serves both paths, inject `Instance` instead. If your application serves the sitemap path in any way that the build sees, the extension serves no sitemap. The build sees static files, JAX-RS paths and Reactive Routes. A JAX-RS path includes the `@ApplicationPath` or -`quarkus.rest.path` prefix. The limits on the `robots.txt` page apply here too. See +REST path prefix. The limits on the `robots.txt` page apply here too. See xref:robots.adoc#when-your-application-serves-robotstxt[When your application serves robots.txt]. == Switch the feature off diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNow.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNow.java index 2e4c3d1..f257062 100644 --- a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNow.java +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNow.java @@ -16,6 +16,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import jakarta.annotation.PreDestroy; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -23,7 +24,6 @@ import io.quarkus.arc.DefaultBean; import org.sitenetsoft.quarkus.seo.IndexNow; -import org.sitenetsoft.quarkus.seo.schema.JsonWriter; /** * Submit URLs to the IndexNow endpoint with the JDK HTTP client. @@ -75,6 +75,19 @@ public DefaultIndexNow(SeoConfig config, IndexNowKey key, SeoSettings settings) this.siteUrl = siteUrl; } + /** + * Shut down the HTTP client without waiting for requests in flight. + * A submission that this cuts short is lost, as if the endpoint had failed. + */ + @PreDestroy + void close() { + try { + client.shutdownNow(); + } catch (RuntimeException e) { + LOG.debugf(e, "IndexNow client shutdown failed"); + } + } + @Override public CompletionStage submit(String... paths) { return submit(paths == null ? List. of() : Arrays.asList(paths)); @@ -117,7 +130,7 @@ private CompletionStage send(Collection paths) { if (urls.isEmpty()) { return CompletableFuture.completedFuture(null); } - String file = "/" + key.key() + ".txt"; + String file = SeoPaths.indexNowKey(key.key()); String keyLocation = key.underRootPath() ? Urls.absolute(base, file) : Urls.hostRoot(base) + file; List> requests = new ArrayList<>(); for (String body : body(host, key.key(), keyLocation, new ArrayList<>(urls))) { @@ -169,18 +182,18 @@ static List body(String host, String key, String keyLocation, List bodies = new ArrayList<>(); for (int from = 0; from < urls.size(); from += MAX_URLS) { StringBuilder out = new StringBuilder("{\"host\":"); - JsonWriter.string(out, host); + Json.string(out, host); out.append(",\"key\":"); - JsonWriter.string(out, key); + Json.string(out, key); out.append(",\"keyLocation\":"); - JsonWriter.string(out, keyLocation); + Json.string(out, keyLocation); out.append(",\"urlList\":["); int to = Math.min(urls.size(), from + MAX_URLS); for (int i = from; i < to; i++) { if (i > from) { out.append(','); } - JsonWriter.string(out, urls.get(i)); + Json.string(out, urls.get(i)); } bodies.add(out.append("]}").toString()); } diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/Json.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/Json.java new file mode 100644 index 0000000..5be0883 --- /dev/null +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/Json.java @@ -0,0 +1,50 @@ +package org.sitenetsoft.quarkus.seo.runtime; + +/** + * Writes JSON strings for the generated files. + * + *

+ * This class is internal. Application code does not use it. + */ +public final class Json { + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Json() { + } + + /** + * Appends a quoted and escaped JSON string. + * + *

+ * The method escapes the less-than sign, so the text stays safe inside a script element. It writes the same + * text as the escape routine of the schema package. + * + * @param out the target + * @param value the text to quote + */ + public static void string(StringBuilder out, String value) { + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20 || c == '\u2028' || c == '\u2029' || c == '<') { + out.append("\\u").append(HEX[(c >> 12) & 0xF]).append(HEX[(c >> 8) & 0xF]) + .append(HEX[(c >> 4) & 0xF]).append(HEX[c & 0xF]); + } else { + out.append(c); + } + } + } + } + out.append('"'); + } +} diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/ManifestWriter.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/ManifestWriter.java index 5911c3a..f47aea8 100644 --- a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/ManifestWriter.java +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/ManifestWriter.java @@ -4,8 +4,6 @@ import java.util.Optional; import java.util.TreeMap; -import org.sitenetsoft.quarkus.seo.schema.JsonWriter; - /** * Write the JSON text of a web manifest. */ @@ -77,9 +75,9 @@ private static void member(StringBuilder out, boolean[] first, String key, Strin return; } separator(out, first); - JsonWriter.string(out, key); + Json.string(out, key); out.append(':'); - JsonWriter.string(out, value); + Json.string(out, value); } private static void separator(StringBuilder out, boolean[] first) { diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SecurityTxtHandler.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SecurityTxtHandler.java index 01252c3..854410f 100644 --- a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SecurityTxtHandler.java +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SecurityTxtHandler.java @@ -11,8 +11,6 @@ * Serve security.txt on a worker thread. */ final class SecurityTxtHandler implements Handler { - - private static final String PATH = "/.well-known/security.txt"; private static final String CONTENT_TYPE = "text/plain; charset=utf-8"; private final boolean underRootPath; @@ -36,7 +34,7 @@ public void handle(RoutingContext context) { SeoConfig config = Arc.container().instance(SeoConfig.class).get(); String baseUrl = Arc.container().instance(BaseUrlResolver.class).get().resolve(context); String root = underRootPath ? Urls.absolute(baseUrl, "") : Urls.hostRoot(baseUrl); - Optional body = SecurityTxtWriter.write(config.securityTxt(), root + PATH); + Optional body = SecurityTxtWriter.write(config.securityTxt(), root + SeoPaths.SECURITY_TXT); if (body.isEmpty()) { context.response().setStatusCode(404).end(); return; diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoPaths.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoPaths.java index 2aefcb6..2512920 100644 --- a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoPaths.java +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoPaths.java @@ -8,9 +8,28 @@ */ public final class SeoPaths { + /** The path of the robots file, relative to the host root or the root path. */ + public static final String ROBOTS_TXT = "/robots.txt"; + + /** The path of the security contact file, relative to the host root or the root path. */ + public static final String SECURITY_TXT = "/.well-known/security.txt"; + + /** The path of the llms.txt file, relative to the host root or the root path. */ + public static final String LLMS_TXT = "/llms.txt"; + private SeoPaths() { } + /** + * Build the path of the IndexNow key file. + * + * @param key the IndexNow key + * @return the key file path, for example {@code /abc123.txt} + */ + public static String indexNowKey(String key) { + return "/" + key + ".txt"; + } + /** * Normalize a path. * diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoValues.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoValues.java index 1bf4991..2320c91 100644 --- a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoValues.java +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/SeoValues.java @@ -581,8 +581,9 @@ public List extraMeta() { * *

* Each field set here replaces the field of the lower layer. The title and the absolute title form one group. - * The image, its width, its height and its alt text form another group. When this layer sets any field of a - * group, the whole group comes from this layer. The lists hold the lower entries first. + * The title group is replaced whole. For the image group, a layer that sets the image replaces the image, its + * width, its height and its alt text. A layer that sets only the width, the height or the alt text keeps the + * lower image and replaces just those fields. The lists hold the lower entries first. * * @param lower the layer below this one * @return a new object with the merged values @@ -608,12 +609,21 @@ private void copyFrom(SeoValues source) { if (source.type != null) { type = source.type; } - if (source.image != null || source.imageWidth != null || source.imageHeight != null - || source.imageAlt != null) { + if (source.image != null) { image = source.image; imageWidth = source.imageWidth; imageHeight = source.imageHeight; imageAlt = source.imageAlt; + } else { + if (source.imageWidth != null) { + imageWidth = source.imageWidth; + } + if (source.imageHeight != null) { + imageHeight = source.imageHeight; + } + if (source.imageAlt != null) { + imageAlt = source.imageAlt; + } } if (source.author != null) { author = source.author; diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCapture.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCapture.java index 6fde1a7..adf474d 100644 --- a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCapture.java +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCapture.java @@ -20,13 +20,16 @@ * slash. A caller that fetches a page adds the root path in front of the key and reads the result with the key. * *

- * The bean exists in dev mode only. The capture keeps at most 1,000 paths and drops the oldest path first. + * The bean exists in dev mode only. The capture keeps the heads of at most 1,000 paths and drops the oldest head + * first. It remembers at most 10,000 logged paths and drops the oldest path first. The logged set has the larger bound + * because a logged path costs one key, and a sitemap check marks every URL of the sitemap. */ @Singleton public class SeoDevCapture { private static final Logger LOG = Logger.getLogger("org.sitenetsoft.quarkus.seo.runtime.SeoDevCapture"); private static final int MAX_PATHS = 1000; + private static final int MAX_LOGGED = 10_000; private final Map captured = new LinkedHashMap<>() { @Override @@ -38,7 +41,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { private final Map logged = new LinkedHashMap<>() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_PATHS; + return size() > MAX_LOGGED; } }; @@ -78,8 +81,10 @@ public Optional last(String path) { * Forget the stored result of a path and mark the path as logged. * *

- * The Dev UI calls this method before it fetches a page. The next result of the path is stored and read, but its - * warnings do not log, because the Dev UI shows them. + * The Dev UI calls this method before it fetches any page: the checked page, its hreflang alternates and every + * sitemap URL. The next result of the path is stored and read. The path stays marked as logged until the next live + * reload, which replaces this bean and clears the marks. Neither the Dev UI fetch nor later browsing logs the + * warnings of a logged path. The Dev UI shows them instead. * * @param path the request path, relative to the HTTP root path */ @@ -89,4 +94,16 @@ public void expect(String path) { logged.put(path, Boolean.TRUE); } } + + /** + * Tell whether the warnings of a path are already logged or suppressed. + * + * @param path the request path, relative to the HTTP root path + * @return true when the path is in the logged set + */ + boolean logged(String path) { + synchronized (captured) { + return logged.containsKey(path); + } + } } diff --git a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/schema/JsonWriter.java b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/schema/JsonWriter.java index 3e3f729..8e68e83 100644 --- a/runtime/src/main/java/org/sitenetsoft/quarkus/seo/schema/JsonWriter.java +++ b/runtime/src/main/java/org/sitenetsoft/quarkus/seo/schema/JsonWriter.java @@ -14,7 +14,7 @@ *

* This class is internal. Application code does not use it. */ -public final class JsonWriter { +final class JsonWriter { private static final char[] HEX = "0123456789abcdef".toCharArray(); @@ -98,7 +98,7 @@ static void list(StringBuilder out, List list) { * @param out the target * @param text the text to quote */ - public static void string(StringBuilder out, String text) { + static void string(StringBuilder out, String text) { out.append('"'); for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); diff --git a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/PublicApiTest.java b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/PublicApiTest.java index 430a58c..7df8053 100644 --- a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/PublicApiTest.java +++ b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/PublicApiTest.java @@ -15,7 +15,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -23,15 +25,23 @@ class PublicApiTest { private static final String PACKAGE = "org.sitenetsoft.quarkus.seo"; + private static final String SCHEMA = PACKAGE + ".schema"; private static final String INTERNAL = PACKAGE + ".runtime."; @Test void publicSignaturesExposeNoInternalType() throws Exception { List leaks = new ArrayList<>(); - List> types = publicTypes(); + List> types = publicTypes(PACKAGE); + types.addAll(publicTypes(SCHEMA)); assertThat(types).contains(Seo.class, SeoCaches.class, ResolvedSeo.Meta.class, IndexNow.class); for (Class type : types) { + Set methods = new LinkedHashSet<>(List.of(type.getMethods())); for (Method method : type.getDeclaredMethods()) { + if (Modifier.isProtected(method.getModifiers())) { + methods.add(method); + } + } + for (Method method : methods) { if (visible(method.getModifiers()) && !method.isSynthetic()) { check(leaks, type.getName() + "#" + method.getName(), method.getGenericReturnType()); for (Type parameter : method.getGenericParameterTypes()) { @@ -55,6 +65,13 @@ void publicSignaturesExposeNoInternalType() throws Exception { assertThat(leaks).isEmpty(); } + @Test + void schemaPackageExposesOnlyTheDocumentedTypes() throws Exception { + assertThat(publicTypes(SCHEMA)).extracting(Class::getSimpleName) + .containsExactlyInAnyOrder("Article", "BreadcrumbList", "JsonLd", "Organization", "SchemaNode", + "WebSite"); + } + private static boolean visible(int modifiers) { return Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers); } @@ -85,9 +102,10 @@ private static void check(List leaks, String member, Type type) { } } - private static List> publicTypes() throws IOException, URISyntaxException, ClassNotFoundException { + private static List> publicTypes(String packageName) + throws IOException, URISyntaxException, ClassNotFoundException { Path directory = Path.of(Seo.class.getProtectionDomain().getCodeSource().getLocation().toURI()) - .resolve(PACKAGE.replace('.', '/')); + .resolve(packageName.replace('.', '/')); List> types = new ArrayList<>(); try (Stream files = Files.list(directory)) { for (Path file : files.toList()) { @@ -95,7 +113,7 @@ private static List> publicTypes() throws IOException, URISyntaxExcepti if (!name.endsWith(".class") || name.equals("package-info.class")) { continue; } - Class type = Class.forName(PACKAGE + "." + name.substring(0, name.length() - ".class".length()), + Class type = Class.forName(packageName + "." + name.substring(0, name.length() - ".class".length()), false, PublicApiTest.class.getClassLoader()); if (Modifier.isPublic(type.getModifiers()) && !type.isAnonymousClass()) { types.add(type); diff --git a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNowTest.java b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNowTest.java index 8821b1a..2a52b94 100644 --- a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNowTest.java +++ b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/DefaultIndexNowTest.java @@ -10,7 +10,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletionStage; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterEach; @@ -23,6 +25,7 @@ class DefaultIndexNowTest { private HttpServer server; private final List received = new CopyOnWriteArrayList<>(); + private final CountDownLatch release = new CountDownLatch(1); private String endpoint; @BeforeEach @@ -33,12 +36,21 @@ void start() throws IOException { exchange.sendResponseHeaders(202, -1); exchange.close(); }); + server.createContext("/slow", exchange -> { + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + exchange.close(); + }); server.start(); endpoint = "http://127.0.0.1:" + server.getAddress().getPort() + "/indexnow"; } @AfterEach void stop() { + release.countDown(); server.stop(0); } @@ -100,4 +112,25 @@ void noSiteUrlDoesNothing() throws Exception { indexNow(Optional.empty(), false).submit("/a").toCompletableFuture().get(10, TimeUnit.SECONDS); assertThat(received).isEmpty(); } + + @Test + void closeDoesNotWaitForASlowEndpoint() throws Exception { + SeoSettings settings = new SeoSettings(Optional.empty(), true, null, Optional.empty(), "/"); + DefaultIndexNow indexNow = new DefaultIndexNow(HttpClient.newHttpClient(), + endpoint.replace("/indexnow", "/slow"), new IndexNowKey("abcd1234", false), settings, + Optional.of("https://ex.com")); + CompletionStage stage = indexNow.submit("/a"); + long start = System.nanoTime(); + indexNow.close(); + assertThat(System.nanoTime() - start).isLessThan(TimeUnit.SECONDS.toNanos(2)); + stage.toCompletableFuture().get(2, TimeUnit.SECONDS); + } + + @Test + void submitAfterCloseCompletesNormally() throws Exception { + DefaultIndexNow indexNow = indexNow(Optional.of("https://ex.com"), false); + indexNow.close(); + indexNow.submit("/a").toCompletableFuture().get(2, TimeUnit.SECONDS); + assertThat(received).isEmpty(); + } } diff --git a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoResolverTest.java b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoResolverTest.java index 691418d..af37daf 100644 --- a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoResolverTest.java +++ b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoResolverTest.java @@ -84,6 +84,14 @@ void javaTitleReplacesTagAbsoluteTitle() { assertThat(seo.ogTitle()).contains("About"); } + @Test + void javaAltKeepsTheTagImage() { + ResolvedSeo.Image image = resolve(new SeoValues().image("/t.png"), new SeoValues().imageAlt("Java alt")) + .image().orElseThrow(); + assertThat(image.url()).endsWith("/t.png"); + assertThat(image.alt()).isEqualTo("Java alt"); + } + @Test void higherImageDropsTheSizeAndAltOfTheLowerImage() { SeoValues tag = new SeoValues().image("/tag.png").imageWidth(1200).imageHeight(630).imageAlt("Tag"); diff --git a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoValuesTest.java b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoValuesTest.java index b40012e..c2063ca 100644 --- a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoValuesTest.java +++ b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/SeoValuesTest.java @@ -34,6 +34,34 @@ void imageFieldsAreOneGroup() { assertThat(merged.imageAlt()).isNull(); } + @Test + void altAloneKeepsTheLowerImage() { + SeoValues lower = new SeoValues().image("/a.png").imageWidth(100).imageAlt("A"); + SeoValues merged = new SeoValues().imageAlt("B").over(lower); + assertThat(merged.image()).isEqualTo("/a.png"); + assertThat(merged.imageWidth()).isEqualTo(100); + assertThat(merged.imageAlt()).isEqualTo("B"); + } + + @Test + void sizeAloneKeepsTheLowerImage() { + SeoValues lower = new SeoValues().image("/a.png").imageAlt("A"); + SeoValues merged = new SeoValues().imageWidth(200).imageHeight(100).over(lower); + assertThat(merged.image()).isEqualTo("/a.png"); + assertThat(merged.imageWidth()).isEqualTo(200); + assertThat(merged.imageHeight()).isEqualTo(100); + assertThat(merged.imageAlt()).isEqualTo("A"); + } + + @Test + void imageReplacesTheWholeGroup() { + SeoValues lower = new SeoValues().image("/a.png").imageWidth(100).imageAlt("A"); + SeoValues merged = new SeoValues().image("/b.png").over(lower); + assertThat(merged.image()).isEqualTo("/b.png"); + assertThat(merged.imageWidth()).isNull(); + assertThat(merged.imageAlt()).isNull(); + } + @Test void listsAddLowerFirst() { SeoValues merged = new SeoValues().alternate(Locale.FRENCH, "/fr") diff --git a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCaptureTest.java b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCaptureTest.java new file mode 100644 index 0000000..9975ab1 --- /dev/null +++ b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/runtime/devui/SeoDevCaptureTest.java @@ -0,0 +1,48 @@ +package org.sitenetsoft.quarkus.seo.runtime.devui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.sitenetsoft.quarkus.seo.ResolvedSeo; + +class SeoDevCaptureTest { + + private static ResolvedSeo seo() { + return new ResolvedSeo("Title", Optional.of("A description."), List.of(), Optional.empty(), List.of(), + "website", Optional.of("Title"), Optional.empty(), "en_US", List.of(), Optional.empty(), + Optional.empty(), "summary", Optional.empty(), Optional.empty(), List.of(), Optional.empty(), + List.of(), "", Optional.empty()); + } + + @Test + void browsedPathStaysLoggedAfterManyExpects() { + SeoDevCapture capture = new SeoDevCapture(); + capture.record("/a", seo()); + for (int i = 0; i < 1500; i++) { + capture.expect("/p" + i); + } + assertThat(capture.logged("/a")).isTrue(); + } + + @Test + void expectedPathDoesNotLogLater() { + SeoDevCapture capture = new SeoDevCapture(); + capture.expect("/b"); + capture.record("/b", seo()); + assertThat(capture.logged("/b")).isTrue(); + assertThat(capture.last("/b")).isPresent(); + } + + @Test + void capturedHeadsStayBounded() { + SeoDevCapture capture = new SeoDevCapture(); + for (int i = 0; i <= 1000; i++) { + capture.record("/p" + i, seo()); + } + assertThat(capture.last("/p0")).isEmpty(); + assertThat(capture.last("/p1000")).isPresent(); + } +} diff --git a/runtime/src/test/java/org/sitenetsoft/quarkus/seo/schema/JsonEscapingParityTest.java b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/schema/JsonEscapingParityTest.java new file mode 100644 index 0000000..898e135 --- /dev/null +++ b/runtime/src/test/java/org/sitenetsoft/quarkus/seo/schema/JsonEscapingParityTest.java @@ -0,0 +1,24 @@ +package org.sitenetsoft.quarkus.seo.schema; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.sitenetsoft.quarkus.seo.runtime.Json; + +/** + * The schema package is JDK-only, so it keeps its own escape routine. This test keeps it equal to the runtime copy. + */ +class JsonEscapingParityTest { + + @ParameterizedTest + @ValueSource(strings = { "", "plain", "a\"b", "back\\slash", "", "\u0000\u0001\u001f", "\n\r\t\b\f", + "\u2028\u2029", "é😀" }) + void bothWritersEscapeAlike(String text) { + StringBuilder schema = new StringBuilder(); + StringBuilder runtime = new StringBuilder(); + JsonWriter.string(schema, text); + Json.string(runtime, text); + assertThat(runtime).hasToString(schema.toString()); + } +}