diff --git a/docs/assets/badges/dotnet-10.svg b/docs/assets/badges/dotnet-10.svg
new file mode 100644
index 0000000..034a27c
--- /dev/null
+++ b/docs/assets/badges/dotnet-10.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/assets/badges/license-mit.svg b/docs/assets/badges/license-mit.svg
new file mode 100644
index 0000000..39d2244
--- /dev/null
+++ b/docs/assets/badges/license-mit.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/assets/badges/nuget.svg b/docs/assets/badges/nuget.svg
new file mode 100644
index 0000000..19eccbb
--- /dev/null
+++ b/docs/assets/badges/nuget.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
index c7585d4..b768fb6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -16,10 +16,9 @@ title: Stratara — CQRS and Event Sourcing for .NET
GitHub
-
-
-
-
+
+
+
diff --git a/docs/legal/privacy.md b/docs/legal/privacy.md
index 47ada53..7ebf2f8 100644
--- a/docs/legal/privacy.md
+++ b/docs/legal/privacy.md
@@ -71,9 +71,9 @@ necessary storage; the legal basis is our legitimate interest in providing a fun
## Cookies
-This site sets no cookies. It embeds no advertising networks, and fonts are served from this site
-rather than from an external font CDN. Only the localStorage values described above are stored, and
-only the analytics tool described below is loaded — and that one only with your consent.
+This site sets no cookies. It embeds no advertising networks. Only the localStorage values
+described above are stored, and only the analytics tool described below is loaded — and that one
+only with your consent.
## Web analytics (Umami)
@@ -92,14 +92,12 @@ any time with effect for the future:
-## Status badges from third parties
+## No third-party requests before you agree
-Some pages — the start page above all — show status badges that are loaded as images from
-`img.shields.io` (Shields.io) and from `github.com` (GitHub Actions). Because your browser fetches
-these images directly from those providers, your IP address and your browser's request headers
-reach them when such a page loads. This happens before any consent, because the badges are page
-content rather than analytics; the legal basis is our legitimate interest in showing the current
-build and package status (Art. 6 (1) (f) GDPR). No cookies are set by these requests.
+Apart from the analytics described above, this site loads nothing from another host. Fonts, icons,
+diagrams, the search index and the status badges are all served from this site itself — the badges
+are committed images, refreshed when a release is cut, rather than hot-linked from a badge service.
+Until you accept the analytics notice, your browser therefore talks to nobody but this site's host.
## External links
diff --git a/scripts/refresh-badges.sh b/scripts/refresh-badges.sh
new file mode 100755
index 0000000..81688fd
--- /dev/null
+++ b/scripts/refresh-badges.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+#
+# Regenerate the badge images the documentation site serves from docs/assets/badges/.
+#
+# The site serves them itself rather than hot-linking a badge service, so that a visitor's browser
+# makes no third-party request before that visitor has agreed to anything. The cost of that is
+# these files going stale, which is what `LandingBadgeTests` fails on and this script fixes.
+#
+# The version comes from the newest dated section of CHANGELOG.md — the same source llms.txt uses,
+# and for the same reason: names the version being worked toward, which stays
+# unreleased for the whole cycle.
+#
+# Usage: ./scripts/refresh-badges.sh
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BADGE_DIR="${ROOT_DIR}/docs/assets/badges"
+
+# BSD and GNU sed disagree about \+ in a basic regex, so the match is grep -E's job and sed
+# only trims the brackets off what it found.
+VERSION="$(grep -m1 -E '^## \[[0-9]+\.[0-9]+\.[0-9]+\].*[0-9]{4}-[0-9]{2}-[0-9]{2}' \
+ "${ROOT_DIR}/CHANGELOG.md" | sed -e 's/^## \[//' -e 's/\].*$//')"
+if [[ -z "${VERSION}" ]]; then
+ echo "Error: no dated release section found in CHANGELOG.md" >&2
+ exit 1
+fi
+
+mkdir -p "${BADGE_DIR}"
+
+fetch() {
+ local name="$1" url="$2"
+ curl -fsS --retry 3 -o "${BADGE_DIR}/${name}.svg" "${url}"
+ echo " ${name}.svg"
+}
+
+echo "Refreshing badges for ${VERSION}:"
+fetch nuget "https://img.shields.io/badge/NuGet-v${VERSION}-007ec6?logo=nuget"
+fetch license-mit "https://img.shields.io/badge/license-MIT-blue.svg"
+fetch dotnet-10 "https://img.shields.io/badge/.NET-10-512BD4.svg?logo=dotnet"
+
+echo
+echo "Done. These files are committed, not fetched at page load — that is the point."
diff --git a/tests/Stratara.Documentation.Tests/LandingBadgeTests.cs b/tests/Stratara.Documentation.Tests/LandingBadgeTests.cs
new file mode 100644
index 0000000..32e055a
--- /dev/null
+++ b/tests/Stratara.Documentation.Tests/LandingBadgeTests.cs
@@ -0,0 +1,60 @@
+using System.Text.RegularExpressions;
+
+namespace Stratara.Documentation.Tests;
+
+///
+/// The documentation site serves its badges from docs/assets/badges/ rather than from a
+/// badge service, so that a visitor's browser makes no third-party request before that visitor has
+/// agreed to anything. That property is only worth having if it holds for every page, and a served
+/// badge only stays true if something notices when it stops being.
+///
+/// The release badge is checked against the newest dated section of the changelog for the same
+/// reason llms.txt is: VersionPrefix names the version being worked toward, which
+/// stays unreleased for the whole cycle, so comparing against it would advertise a version nobody
+/// can install.
+///
+///
+public partial class LandingBadgeTests
+{
+ [GeneratedRegex(@"^##\s*\[(\d+\.\d+\.\d+)\][^\n]*?\d{4}-\d{2}-\d{2}", RegexOptions.Multiline)]
+ private static partial Regex NewestReleasedVersionPattern();
+
+ [GeneratedRegex(@"v(\d+\.\d+\.\d+)")]
+ private static partial Regex BadgeVersionPattern();
+
+ // Quoted, single-quoted, bare and protocol-relative all reach another host, and so does a
+ // Markdown image. A guard that only knew the shape we happen to write today would pass the
+ // first time somebody wrote it differently, which is the one moment it exists for.
+ [GeneratedRegex("""]+src\s*=\s*["']?(?:https?:)?//|!\[[^\]]*\]\(\s*(?:https?:)?//""")]
+ private static partial Regex ExternallyHostedImagePattern();
+
+ [Fact]
+ public void TheReleaseBadge_NamesTheNewestRelease()
+ {
+ var root = RepositoryRoot.Locate();
+ var release = NewestReleasedVersionPattern()
+ .Match(File.ReadAllText(Path.Combine(root, "CHANGELOG.md")));
+ var badge = BadgeVersionPattern()
+ .Match(File.ReadAllText(Path.Combine(root, "docs", "assets", "badges", "nuget.svg")));
+
+ // Without these, a changelog heading or badge format we stopped recognising would leave
+ // both sides empty and the comparison would agree that nothing is wrong.
+ Assert.True(release.Success, "No dated release section was found in CHANGELOG.md.");
+ Assert.True(badge.Success, "No version was found in docs/assets/badges/nuget.svg.");
+ Assert.Equal(release.Groups[1].Value, badge.Groups[1].Value);
+ }
+
+ [Fact]
+ public void NoDocumentationPage_LoadsAnImageFromAnotherHost()
+ {
+ var offenders = DocumentationFiles.Enumerate()
+ .Where(file => ExternallyHostedImagePattern().IsMatch(File.ReadAllText(file)))
+ .ToList();
+
+ Assert.True(
+ offenders.Count == 0,
+ $"These pages load an image from another host, which reaches that host before the "
+ + $"visitor has consented to anything. Serve the image from docs/assets/ instead: "
+ + $"{string.Join(", ", offenders)}");
+ }
+}