Turned the analytics tracker off by default in the template.

This commit is contained in:
2026-08-13 02:57:47 +02:00
parent b68eac4375
commit 2206d71997
13 changed files with 178 additions and 71 deletions
@@ -20,7 +20,7 @@ public struct Analytics: Sendable {
/// Whether the tracker honors the visitor's browser Do Not Track preference.
public let doNotTrack: Bool
/// The comma-delimited domains the tracker reports from; visits from any other host are ignored.
/// The comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host.
public let domains: String
/// Whether the tracker collects Core Web Vitals from visitors (requires an Umami instance at v3.1 or newer).
@@ -41,7 +41,7 @@ public struct Analytics: Sendable {
/// - Parameters:
/// - scriptURL: the URL the tracker script is loaded from.
/// - websiteID: the analytics website identifier the tracker reports as.
/// - domains: the comma-delimited domains the tracker reports from; visits from any other host are ignored.
/// - domains: the comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host.
/// - excludeHash: whether the tracker drops the URL fragment from reported pageviews; defaults to `true`.
/// - doNotTrack: whether the tracker honors the visitor's browser Do Not Track preference; defaults to `true`.
/// - performance: whether the tracker collects Core Web Vitals (requires Umami v3.1 or newer); defaults to `true`.
@@ -66,19 +66,25 @@ public struct Analytics: Sendable {
// MARK: Computed
/// The tracker script's attributes, in a stable order: the website id and the reporting domains, then each enabled behavior flag.
/// The tracker script's attributes, in a stable order: the website id, the reporting domains when filtered, then each enabled behavior flag.
///
/// A disabled flag is left out entirely, since the tracker treats an absent attribute as off. Each `name` is a full attribute name following the
/// Umami `data-` convention, which a page applies to the deferred script verbatim so the page renders the tracker without knowing its shape.
/// A disabled flag is left out entirely, since the tracker treats an absent attribute as off. An empty ``domains`` is left out for the same reason:
/// the tracker reads the attribute as an allowlist, so rendering it empty would filter out every host rather than none. Each `name` is a full
/// attribute name following the Umami `data-` convention, which a page applies to the deferred script verbatim so the page renders the
/// tracker without knowing its shape.
public var attributes: [Attribute] {
var attributes = [(
name: "data-website-id",
value: websiteID
), (
name: "data-domains",
value: domains
)]
if !domains.isEmpty {
attributes.append((
name: "data-domains",
value: domains
))
}
if excludeHash {
attributes.append((
name: "data-exclude-hash",
@@ -120,4 +120,16 @@ struct AnalyticsTests {
])
}
@Test
func `omits the domains filter when it is empty`() {
// The tracker reads the attribute as an allowlist, so rendering it empty would filter out every host rather than none.
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: ""
)
#expect(!analytics.attributes.map(\.name).contains("data-domains"))
}
}
+6 -2
View File
@@ -194,9 +194,13 @@ Next steps:
- $W/Resources/Static/site.webmanifest (name / short_name)
2. Set a real database password in a git-ignored $W/.env
(the committed .env.local defaults the password to the slug — do NOT ship that).
3. Point the git remote at your new repository:
3. Analytics ships OFF, and stays off until you opt in. To enable it: point
String.Analytics.origin at your own Umami instance (it defaults to the
reserved https://analytics.example.com), allow that origin in
security.contentSecurityPolicy, then set ANALYTICS_WEBSITE_ID.
4. Point the git remote at your new repository:
git remote set-url origin <new-repo-url> # or 'git remote add origin ...'
4. Build and run:
5. Build and run:
cd $W && make site-run
The Persistence package still ships an ExampleRecord / ExampleRepository sample
+16 -14
View File
@@ -10,7 +10,7 @@ The service:
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
- Embeds a cookieless [Umami](https://umami.is) tracker on the pages that provide one, configured through the `analytics.*` keys (see [Analytics](#analytics)).
- Embeds a cookieless [Umami](https://umami.is) tracker on both pages once a deployment configures one; it ships **off**, so an unconfigured copy requests no third-party script (see [Analytics](#analytics)).
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
- Stamps a hardened set of security headers on every response.
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a PostgreSQL server, selected by a single configuration key.
@@ -53,14 +53,15 @@ The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET`
### Page metadata
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
Five of those are optional and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
Four of those are page-authored, optional, and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
| Property | Renders as | Notes |
| --- | --- | --- |
| `summary` | `<meta name="description">` | The page's one-line description. |
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. |
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:profiles:)` builds the site-wide `Organization` + `WebSite` pair. The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
| `analytics` | `<link rel="preconnect">` + a deferred `<script>` | An `Analytics` tracker — script URL, website identifier, reported domains, and the behavior flags, following the [Umami](https://umami.is) `data-` attribute convention. Unlike the structured data it *is* executable, so the `Content-Security-Policy` must allow its origin; with recorder mode on, a second deferred script follows it. The executable builds one from the `analytics.*` keys (see [Analytics](#analytics)). |
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas.
@@ -141,26 +142,27 @@ See [Persistence](#persistence-1) below for the workflow.
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
### Site
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `site.origin` | `SITE_ORIGIN` | `https://loud.amsterdam` | Public origin the site is served at (scheme and host, no trailing slash). The pages derive their canonical URL and other absolute links (social card image, structured data) from it, so a staging deployment can point it at itself instead of leaking the production origin into its markup. |
### Analytics
The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:
1. Point `String.Analytics.origin` (`Sources/Library/Public/Extensions/String+Constants.swift`) at your own [Umami](https://umami.is) instance. It ships as `https://analytics.example.com`, an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) reserved domain, so an unconfigured copy can never report to somebody else's server.
2. Extend `security.contentSecurityPolicy` to allow that origin in `script-src` and `connect-src` — the default policy is `'self'`-only, so the tracker is blocked until you do.
3. Set `ANALYTICS_WEBSITE_ID` on the deployment.
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `analytics.websiteID` | `ANALYTICS_WEBSITE_ID` | `f28681d6-20e8-43f3-9c3b-5d6a0f8e0591` | The analytics website identifier the tracker on both pages reports as. **Set it to an empty string to disable analytics entirely** the tracker script is then omitted from the pages. |
| `analytics.domains` | `ANALYTICS_DOMAINS` | `loud.amsterdam` | Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. |
| `analytics.recorder` | `ANALYTICS_RECORDER` | `true` | Whether the pages also embed the session recorder script (`recorder.js`, loaded from the tracker's origin) alongside the tracker. Set it to `false` to disable session recording on a deployment. |
| `analytics.websiteID` | `ANALYTICS_WEBSITE_ID` | _(empty — analytics off)_ | The analytics website identifier the tracker on both pages reports as. While it is empty the tracker script is omitted entirely; clearing it again disables analytics on a deployment. |
| `analytics.domains` | `ANALYTICS_DOMAINS` | _(empty — every host reports)_ | Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. Left empty, the attribute is omitted and no host is filtered out. |
| `analytics.recorder` | `ANALYTICS_RECORDER` | `false` | Whether the pages also embed the session recorder script (`recorder.js`, loaded from the tracker's origin) alongside the tracker. Session recording is the most invasive thing the tracker does, so it is opted into: set it to `true` to enable it on a deployment. |
The tracker's origin (`https://analytics.rock-n-code.com`) is not configurable: it is single-sourced in code so the tracker tag and the `Content-Security-Policy` that must allow it (`security.contentSecurityPolicy` below) always agree. The pages also emit a `preconnect` hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.
The tracker's origin is not a configuration key: it is single-sourced in code so the tracker tag and the `Content-Security-Policy` that must allow it (`security.contentSecurityPolicy` below) cannot drift apart at runtime. The pages emit a `preconnect` hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.
> **Keep `analytics.domains` in sync with `site.origin`.** Both encode the deployment's public host — the hosts the tracker reports from, and the host the pages are served at. Override one without the other (say, pointing a staging deployment at itself) and the domain filter stops matching: every visit is dropped silently, with no error. To disable analytics on a deployment instead, clear `analytics.websiteID` (see above).
> **Set `analytics.domains` to the host the deployment actually serves, or leave it empty.** It is an allowlist: name a host the deployment does not serve (say, pointing a staging box at the production domain) and every visit is dropped silently, with no error. To turn analytics off instead, clear `analytics.websiteID`.
### Security headers
| Config key | Environment variable | Default |
| --- | --- | --- |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; script-src 'self' https://analytics.rock-n-code.com; connect-src 'self' https://analytics.rock-n-code.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
| `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` |
| `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` |
| `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` |
@@ -49,6 +49,7 @@ func application(
router: router(
staticFilesPath: reader.staticFilesPath,
assetVersion: fingerprintAssets(reader.staticFilesPath),
analytics: reader.analytics,
cacheControl: reader.cacheControl,
compressionMinResponseSize: reader.compressionMinResponseSize,
rateLimit: reader.rateLimit,
@@ -145,9 +146,10 @@ private func logger(
/// - Parameters:
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
/// - analytics: the analytics tracker both pages embed, or `nil` to omit it.
/// - cacheControl: the cache-control directives applied to the served static files.
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
/// - rateLimit: the rate limit applied to the subscription endpoint.
/// - rateLimit: the rate limit applied to the rate-limited routes.
/// - securityHeaders: the security headers applied to every response.
/// - logLevel: the level the request-logging middleware logs at.
/// - probe: the probe consulted by the `HealthController` readiness route.
@@ -155,6 +157,7 @@ private func logger(
private func router(
staticFilesPath: String,
assetVersion: String?,
analytics: Analytics?,
cacheControl: CacheControl,
compressionMinResponseSize: Int,
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
@@ -180,7 +183,8 @@ private func router(
)
LocalizationMiddleware()
NotFoundMiddleware(
assetVersion: assetVersion
assetVersion: assetVersion,
analytics: analytics
)
FileMiddleware(
staticFilesPath,
@@ -190,7 +194,8 @@ private func router(
router.addController {
RootController<AppRequestContext>(
assetVersion: assetVersion
assetVersion: assetVersion,
analytics: analytics
)
HealthController<AppRequestContext>(
probe: probe
@@ -14,19 +14,21 @@ package extension ConfigReader {
// MARK: Computed
/// The analytics tracker the landing page embeds, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves
/// empty a deployment disables analytics entirely by clearing the identifier.
/// The analytics tracker both pages embed, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves empty.
///
/// The identifier is empty by default, so the template serves no tracker at all until a deployment sets `analytics.websiteID` and
/// clearing it again disables analytics entirely.
///
/// The script URL is not configurable: its origin is single-sourced in `String.Analytics`, so the tracker tag and the
/// `Content-Security-Policy` that must allow it derive from one constant and cannot drift apart.
/// `Content-Security-Policy` that must allow it derive from one constant and cannot drift apart. Point that constant at your own
/// instance, and extend `security.contentSecurityPolicy` to allow it, before enabling analytics.
///
/// The `analytics.domains` filter must name the host the pages are served from i.e. the host of ``siteOrigin``. The two keys are
/// independent, so a deployment that overrides `site.origin` without matching `analytics.domains` reports from a host it no longer
/// serves and records nothing; change them together.
/// The `analytics.domains` filter must name the host the pages are served from; it is empty by default, which reports from every host.
/// Set it to a host the deployment does not serve and the tracker silently records nothing.
///
/// Recorder mode is on by default the pages embed the session recorder script alongside the tracker and the `analytics.recorder`
/// flag turns it off for a deployment. The recorder loads from the same origin as the tracker, so the `Content-Security-Policy` needs
/// no extra allowance.
/// Recorder mode is off by default session recording is the most invasive thing the tracker does, so a deployment opts into it
/// deliberately with the `analytics.recorder` flag. When on, the pages embed the session recorder script alongside the tracker; it loads
/// from the same origin, so the `Content-Security-Policy` needs no extra allowance.
var analytics: Analytics? {
let websiteID = string(
forKey: .Analytics.websiteID,
@@ -46,7 +48,7 @@ package extension ConfigReader {
),
recorder: bool(
forKey: .Analytics.recorder,
default: true
default: false
)
)
}
@@ -8,6 +8,9 @@ struct IndexPage {
// MARK: Properties
/// The analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
let analytics: Analytics?
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
@@ -23,10 +26,13 @@ struct IndexPage {
/// - Parameters:
/// - locale: the locale the page content is localized to.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
locale: Locale,
assetVersion: String? = nil
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.locale = locale
self.localize = .init(bundle: .module)
@@ -8,6 +8,9 @@ struct NotFoundPage {
// MARK: Properties
/// The analytics tracker embedded as a deferred script in the document head, or `nil` to omit it.
let analytics: Analytics?
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
@@ -24,10 +27,13 @@ struct NotFoundPage {
/// - locale: the locale the page content is localized to.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
/// default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
locale: Locale,
assetVersion: String? = nil
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.locale = locale
self.localize = .init(bundle: .module)
@@ -23,14 +23,18 @@ public struct RootController<Context: LocalizedRequestContext> {
// MARK: Initializers
/// Creates a root controller.
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - Parameters:
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
public init(
assetVersion: String? = nil
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.responses = .init(bundle: .module) {
IndexPage(
locale: $0,
assetVersion: assetVersion
assetVersion: assetVersion,
analytics: analytics
)
}
}
@@ -6,14 +6,18 @@ public extension NotFoundMiddleware {
// MARK: Initializers
/// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
/// - Parameter assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - Parameters:
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the error page embeds, or `nil` (the default) to omit it.
init(
assetVersion: String? = nil
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.init(bundle: .module) {
NotFoundPage(
locale: $0,
assetVersion: assetVersion
assetVersion: assetVersion,
analytics: analytics
)
}
}
@@ -1,20 +1,25 @@
extension String {
/// A namespace for the analytics default configuration values.
///
/// Analytics ships **off**: ``websiteID`` is empty, so the pages embed no tracker until a deployment sets `analytics.websiteID`. Point
/// ``origin`` at your own instance before enabling it the placeholder is an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606)
/// reserved domain, so an unconfigured copy can never report to someone else's server.
public enum Analytics {
/// The origin the analytics scripts are loaded from and their beacons are sent to (scheme and host, no trailing slash).
///
/// Single-sourced here: both ``scriptURL`` and the session recorder script the pages embed in recorder mode derive from this
/// constant, and the site's `Content-Security-Policy` must allow it.
public static let origin = "https://analytics.rock-n-code.com"
/// constant. It is deliberately not a configuration key the `Content-Security-Policy` must allow the same origin, and a value that
/// can drift at runtime would silently break the tracker it is supposed to permit.
public static let origin = "https://analytics.example.com"
/// The URL the analytics tracker script is loaded from.
public static let scriptURL = "\(origin)/script"
/// The default analytics website identifier the tracker reports as.
public static let websiteID = "f28681d6-20e8-43f3-9c3b-5d6a0f8e0591"
/// The default comma-delimited domains the tracker reports from; visits from any other host are ignored.
/// The default analytics website identifier the tracker reports as: empty, which omits the tracker entirely.
public static let websiteID = ""
/// The default comma-delimited domains the tracker reports from: empty, which reports from every host.
///
/// Keep it paired with the host the pages are served at: a deployment that serves from another host without overriding
/// `analytics.domains` to match reports from a host it no longer serves, so analytics silently records nothing.
public static let domains = "loud.amsterdam"
/// Once set, keep it paired with the host the pages are served at a deployment that serves from another host without matching
/// `analytics.domains` reports from a host it no longer serves, so analytics silently records nothing.
public static let domains = ""
}
/// A namespace for the persistence's default configuration values and recognized tokens.
public enum Database {
@@ -12,36 +12,47 @@ struct ConfigReaderPropertiesTests {
// MARK: Functional tests
@Test
func `analytics to default to the production tracker`() throws {
let analytics = try #require(reader().analytics)
#expect(analytics.scriptURL == .Analytics.scriptURL)
#expect(analytics.websiteID == .Analytics.websiteID)
#expect(analytics.domains == .Analytics.domains)
#expect(analytics.excludeHash)
#expect(analytics.doNotTrack)
#expect(analytics.performance)
#expect(analytics.recorder)
func `analytics to be omitted by default`() {
// The template ships no website identifier, so an unconfigured deployment embeds no tracker at all.
#expect(.Analytics.websiteID == "")
#expect(reader().analytics == nil)
}
@Test
func `analytics to switch recorder mode off when configured`() throws {
func `analytics to be enabled by setting the website id alone`() throws {
let analytics = try #require(reader(values: [
.Analytics.recorder: false
.Analytics.websiteID: "0000-website-id"
]).analytics)
#expect(analytics.scriptURL == .Analytics.scriptURL)
#expect(analytics.websiteID == "0000-website-id")
#expect(analytics.domains.isEmpty)
#expect(analytics.excludeHash)
#expect(analytics.doNotTrack)
#expect(analytics.performance)
#expect(!analytics.recorder)
}
@Test
func `analytics to switch recorder mode on when configured`() throws {
// Session recording is the most invasive thing the tracker does, so it is opted into rather than out of.
let analytics = try #require(reader(values: [
.Analytics.websiteID: "0000-website-id",
.Analytics.recorder: true
]).analytics)
#expect(analytics.recorder)
}
@Test
func `analytics to override the website id and domains when configured`() throws {
let analytics = try #require(reader(values: [
.Analytics.websiteID: "custom-website-id",
.Analytics.domains: "staging.loud.amsterdam"
.Analytics.domains: "staging.example.com"
]).analytics)
#expect(analytics.websiteID == "custom-website-id")
#expect(analytics.domains == "staging.loud.amsterdam")
#expect(analytics.domains == "staging.example.com")
#expect(analytics.scriptURL == .Analytics.scriptURL)
}
@@ -131,6 +131,42 @@ struct RootControllerTests {
}
}
@Test
func `embeds no analytics tracker by default`() async throws {
try await app.test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(!body.contains("data-website-id"))
#expect(!body.contains("analytics"))
}
}
}
@Test
func `embeds the analytics tracker when one is configured`() async throws {
try await app(
analytics: .init(
scriptURL: "https://analytics.example.com/script",
websiteID: "0000-website-id",
domains: "example.com"
)
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(body.contains(#"<link rel="preconnect" href="https://analytics.example.com">"#))
#expect(body.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com""#))
}
}
}
}
// MARK: - Helpers
@@ -140,11 +176,14 @@ private extension RootControllerTests {
// MARK: Methods
/// Builds an application whose root controller appends the given version token to the landing
/// page's asset URLs.
/// - Parameter assetVersion: the version token appended to the page's asset URLs.
/// page's asset URLs and embeds the given analytics tracker.
/// - Parameters:
/// - assetVersion: the version token appended to the page's asset URLs.
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
/// - Returns: the configured application.
func app(
assetVersion: String?
assetVersion: String? = nil,
analytics: Analytics? = nil
) -> some ApplicationProtocol {
let router = Router(context: WebsiteRequestContext.self)
@@ -153,7 +192,8 @@ private extension RootControllerTests {
}
router.addRoutes(RootController<WebsiteRequestContext>(
assetVersion: assetVersion
assetVersion: assetVersion,
analytics: analytics
).routes)
return Application(router: router)