diff --git a/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java b/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java
index b88efcd..5b5f341 100644
--- a/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java
+++ b/src/main/java/com/mccprogrammer/extpulse/publicpage/Badge.java
@@ -10,6 +10,9 @@ final class Badge {
private static final int PADDING = 6;
+ /** The value's color, also given to shields.io for its badges. */
+ static final String COLOR = "256abf";
+
private Badge() {
}
@@ -24,10 +27,11 @@ static String svg(String label, String value) {
%2$s\
\
\
- \
+ \
\
%6$s%8$s""", width, text,
- labelWidth, valueWidth, labelWidth / 2.0, escape(label), labelWidth + valueWidth / 2.0, escape(value));
+ labelWidth, valueWidth, labelWidth / 2.0, escape(label), labelWidth + valueWidth / 2.0, escape(value),
+ COLOR);
}
/** 950, 12.3k, 4.5M: the size of a number at a glance. */
diff --git a/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java b/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java
index 41d7d2d..0cc65ab 100644
--- a/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java
+++ b/src/main/java/com/mccprogrammer/extpulse/publicpage/PublicPages.java
@@ -21,13 +21,20 @@
* What anyone can see without signing in: the home page, and for accounts that chose an
* address, a portfolio page and installs badges. Numbers change once a day, so badges may
* be cached for an hour (GitHub's image proxy does).
+ *
+ * Each badge also comes as JSON for shields.io's endpoint badges
+ * ({@code https://img.shields.io/endpoint?url=...}). The VS Code Marketplace only shows
+ * badges from services it knows, shields.io among them, so that is the one to put in an
+ * extension's README.
*/
@Controller
class PublicPages {
private static final MediaType SVG = MediaType.valueOf("image/svg+xml");
- private static final CacheControl CACHE = CacheControl.maxAge(Duration.ofHours(1)).cachePublic();
+ private static final Duration CACHE_SECONDS = Duration.ofHours(1);
+
+ private static final CacheControl CACHE = CacheControl.maxAge(CACHE_SECONDS).cachePublic();
private final PublicProfiles profiles;
@@ -62,12 +69,29 @@ ResponseEntity totalBadge(@PathVariable String slug) {
/** Installs of one extension, across the stores it is on. */
@GetMapping("/p/{slug}/extensions/{id}/badge.svg")
ResponseEntity extensionBadge(@PathVariable String slug, @PathVariable long id) {
- ExtensionOverview extension = this.stats.overview(owner(slug))
+ return badge(extensionInstalls(slug, id));
+ }
+
+ /** {@link #totalBadge} for shields.io. */
+ @GetMapping("/p/{slug}/badge.json")
+ ResponseEntity totalShieldsBadge(@PathVariable String slug) {
+ long installs = this.stats.overview(owner(slug)).stream().mapToLong(ExtensionOverview::installs).sum();
+ return shieldsBadge(installs);
+ }
+
+ /** {@link #extensionBadge} for shields.io. */
+ @GetMapping("/p/{slug}/extensions/{id}/badge.json")
+ ResponseEntity extensionShieldsBadge(@PathVariable String slug, @PathVariable long id) {
+ return shieldsBadge(extensionInstalls(slug, id));
+ }
+
+ private long extensionInstalls(String slug, long id) {
+ return this.stats.overview(owner(slug))
.stream()
.filter((candidate) -> candidate.id() == id)
.findFirst()
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- return badge(extension.installs());
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND))
+ .installs();
}
private long owner(String slug) {
@@ -81,4 +105,19 @@ private static ResponseEntity badge(long installs) {
.body(Badge.svg("installs", Badge.compact(installs)));
}
+ private static ResponseEntity shieldsBadge(long installs) {
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .cacheControl(CACHE)
+ .body(new ShieldsBadge(1, "installs", Badge.compact(installs), Badge.COLOR,
+ (int) CACHE_SECONDS.toSeconds()));
+ }
+
+ /**
+ * shields.io's endpoint schema. {@code cacheSeconds} asks shields.io to keep it as
+ * long as this site lets anyone cache it.
+ */
+ record ShieldsBadge(int schemaVersion, String label, String message, String color, int cacheSeconds) {
+ }
+
}
diff --git a/src/main/resources/templates/stats/extension.html b/src/main/resources/templates/stats/extension.html
index 3a59565..68f56c4 100644
--- a/src/main/resources/templates/stats/extension.html
+++ b/src/main/resources/templates/stats/extension.html
@@ -146,6 +146,10 @@ Badge
markdown
+ For a README published on the VS Code Marketplace, which only shows badges from services it
+ knows, the same badge through shields.io:
+ markdown
Badges need a public page. Choose its address in Settings.
diff --git a/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java b/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java
index fb4b349..03b1970 100644
--- a/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java
+++ b/src/test/java/com/mccprogrammer/extpulse/publicpage/PublicPagesTests.java
@@ -25,6 +25,7 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
@@ -101,6 +102,26 @@ void badgesAreSvgThatMayBeCachedForAnHour() throws Exception {
.andExpect(content().string(containsString(">12.3k<")));
}
+ @Test
+ void badgesAlsoComeAsJsonForShieldsIo() throws Exception {
+ choose("mccprogrammer");
+
+ this.mvc.perform(get("/p/mccprogrammer/badge.json"))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Type", "application/json"))
+ .andExpect(header().string("Cache-Control", "max-age=3600, public"))
+ .andExpect(jsonPath("$.schemaVersion").value(1))
+ .andExpect(jsonPath("$.label").value("installs"))
+ .andExpect(jsonPath("$.message").value("12.3k"))
+ .andExpect(jsonPath("$.color").value("256abf"))
+ .andExpect(jsonPath("$.cacheSeconds").value(3600));
+ this.mvc.perform(get("/p/mccprogrammer/extensions/{id}/badge.json", this.extension))
+ .andExpect(jsonPath("$.message").value("12.3k"));
+ this.mvc.perform(get("/p/mccprogrammer/extensions/{id}/badge.json", this.extension + 1000))
+ .andExpect(status().isNotFound());
+ this.mvc.perform(get("/p/nobody/badge.json")).andExpect(status().isNotFound());
+ }
+
@Test
void anotherAccountsExtensionHasNoBadgeOnThisPage() throws Exception {
choose("mccprogrammer");
diff --git a/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java b/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java
index bc13c95..b1a5b0e 100644
--- a/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java
+++ b/src/test/java/com/mccprogrammer/extpulse/stats/DashboardPagesTests.java
@@ -88,6 +88,18 @@ void theExtensionPageDrawsBothCharts() throws Exception {
containsString(": 1,100 installs (+10)"), containsString("Choose its address in Settings"))));
}
+ @Test
+ void withAPublicPageTheExtensionPageGivesBothBadgeLines() throws Exception {
+ this.jdbc.sql("UPDATE users SET public_slug = 'mccprogrammer'").update();
+
+ this.mvc.perform(get("/app/extensions/{id}", this.extension).session(this.session))
+ .andExpect(content()
+ .string(allOf(containsString("/p/mccprogrammer/extensions/" + this.extension + "/badge.svg)"),
+ containsString(
+ "https://img.shields.io/endpoint?url=http://localhost/p/mccprogrammer/extensions/"
+ + this.extension + "/badge.json)"))));
+ }
+
@Test
void onlyTheOfferedRangesAreUsed() throws Exception {
this.mvc.perform(get("/app/extensions/{id}", this.extension).param("days", "90").session(this.session))