diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift index 8839c63..8a368cf 100644 --- a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift +++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift @@ -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", diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift index 6b14d1d..39e297c 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift @@ -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")) + } + } diff --git a/Scripts/bootstrap b/Scripts/bootstrap index 9660f1e..b6c7b11 100755 --- a/Scripts/bootstrap +++ b/Scripts/bootstrap @@ -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 # 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 diff --git a/Services/Website/README.md b/Services/Website/README.md index 922ca9a..c8c3314 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -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` | `` | The page's one-line description. | | `canonicalURL` | `` | Absolute URL. | | `socialCard` | Open Graph + Twitter `` 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` | `