Integrated Analytics into the Infrastructure package (#40)

This PR contains the work done to define the `Analytics` type into the _Infrastructure_ package and also, to integrate this type into its `Page` protocol.

Reviewed-on: rock-n-code/loud-amsterdam#40
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-08-04 11:23:25 +02:00
parent fd5aa84924
commit 5c070c2b1a
5 changed files with 220 additions and 5 deletions
@@ -4,8 +4,8 @@ import Foundation
/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
///
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, and the
/// metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body.
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, the analytics
/// tracker script, and the metadata followed by the stylesheet links in the head, and the content followed by the script tags in the body.
public protocol Page: HTMLDocument, Sendable {
// MARK: Associated types
@@ -18,6 +18,9 @@ public protocol Page: HTMLDocument, Sendable {
// MARK: Properties
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
var analytics: Analytics? { get }
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
var assetVersion: String? { get }
@@ -77,14 +80,15 @@ public extension Page {
}
}
/// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script
/// (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head.
/// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags, the ``structuredData`` script and the
/// ``analytics`` tracker script (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head.
///
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` before this markup,
/// and HTML5 allows only one.
///
/// The structured data is an inert data block browsers never execute it, so a site's `Content-Security-Policy` does not apply to it
/// that search engines read for the organization's name, logo, and profiles.
/// that search engines read for the organization's name, logo, and profiles. The analytics tracker, by contrast, is an executable script the
/// policy must allow, and it is `defer`red so it never delays the page render; each behavior flag renders its `data-` attribute only when enabled.
@HTMLBuilder
var head: some HTML {
meta(
@@ -127,6 +131,19 @@ public extension Page {
}
}
if let analytics {
script(
.defer,
.src(analytics.scriptURL)
) {}
.attributes(contentsOf: analytics.attributes.map {
.custom(
name: $0.name,
value: $0.value
)
})
}
metadata
for file in stylesheets {
@@ -140,6 +157,11 @@ public extension Page {
}
}
/// The analytics tracker is omitted unless the page provides one.
var analytics: Analytics? {
nil
}
/// The social card is omitted unless the page provides one.
var socialCard: SocialCard? {
nil
@@ -0,0 +1,97 @@
/// The analytics tracker a page embeds: where the script loads from, which site it reports as, which domains it reports from, and how it behaves.
///
/// A page carries it as an optional value so the ``Page`` scaffolding renders the tracker's deferred `<script>` in the document head, or omits it
/// when the page has none. The attribute names follow the Umami tracker convention. The three behavior flags default to on, and each renders its
/// `data-` attribute only when enabled, since the tracker treats an absent attribute as off.
public struct Analytics: Sendable {
// MARK: Type aliases
public typealias Attribute = (name: String, value: String)
// MARK: Properties
/// Whether the tracker drops the URL fragment from reported pageviews, so fragment navigation does not split a page's views.
public let excludeHash: Bool
/// 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.
public let domains: String
/// Whether the tracker collects Core Web Vitals from visitors (requires an Umami instance at v3.1 or newer).
public let performance: Bool
/// The URL the tracker script is loaded from.
public let scriptURL: String
/// The analytics website identifier the tracker reports as.
public let websiteID: String
// MARK: Initializers
/// Creates an analytics configuration.
/// - 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.
/// - 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`.
public init(
scriptURL: String,
websiteID: String,
domains: String,
excludeHash: Bool = true,
doNotTrack: Bool = true,
performance: Bool = true
) {
self.scriptURL = scriptURL
self.websiteID = websiteID
self.domains = domains
self.excludeHash = excludeHash
self.doNotTrack = doNotTrack
self.performance = performance
}
// MARK: Computed
/// The tracker script's attributes, in a stable order: the website id and the reporting domains, 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.
public var attributes: [Attribute] {
var attributes = [(
name: "data-website-id",
value: websiteID
), (
name: "data-domains",
value: domains
)]
if excludeHash {
attributes.append((
name: "data-exclude-hash",
value: "true"
))
}
if doNotTrack {
attributes.append((
name: "data-do-not-track",
value: "true"
))
}
if performance {
attributes.append((
name: "data-performance",
value: "true"
))
}
return attributes
}
}
@@ -122,6 +122,40 @@ struct PageTests {
))
}
@Test
func `omits the analytics tracker by default`() {
let html = StubPage().render()
#expect(!html.contains("data-website-id"))
}
@Test
func `renders the analytics tracker when provided`() {
let html = StubPage(analytics: .init(
scriptURL: "https://analytics.example.com/script",
websiteID: "0000-website-id",
domains: "example.com"
)).render()
#expect(html.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com" data-exclude-hash="true" data-do-not-track="true" data-performance="true"></script>"#))
}
@Test
func `omits the analytics behavior flags that are disabled`() {
let html = StubPage(analytics: .init(
scriptURL: "https://analytics.example.com/script",
websiteID: "0000-website-id",
domains: "example.com",
excludeHash: true,
doNotTrack: false,
performance: false
)).render()
#expect(html.contains(#"<script defer src="https://analytics.example.com/script" data-website-id="0000-website-id" data-domains="example.com" data-exclude-hash="true"></script>"#))
#expect(!html.contains("data-do-not-track"))
#expect(!html.contains("data-performance"))
}
@Test
func `appends the version token to the asset URLs`() {
let html = StubPage(assetVersion: "0123456789abcdef").render()
@@ -0,0 +1,55 @@
import Testing
@testable import Infrastructure
@Suite(
"Analytics type",
.tags(.type)
)
struct AnalyticsTests {
// MARK: Functional tests
@Test
func `lists the website id and domains with every behavior flag on by default`() {
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: "example.com"
)
#expect(analytics.attributes.map(\.name) == [
"data-website-id",
"data-domains",
"data-exclude-hash",
"data-do-not-track",
"data-performance",
])
#expect(analytics.attributes.map(\.value) == [
"id-123",
"example.com",
"true",
"true",
"true",
])
}
@Test
func `omits the disabled behavior flags`() {
let analytics = Analytics(
scriptURL: "https://analytics.example.com/script",
websiteID: "id-123",
domains: "example.com",
excludeHash: true,
doNotTrack: false,
performance: false
)
#expect(analytics.attributes.map(\.name) == [
"data-website-id",
"data-domains",
"data-exclude-hash",
])
}
}
@@ -7,6 +7,9 @@ struct StubPage: Page {
// MARK: Properties
/// The analytics tracker rendered 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?
@@ -34,6 +37,8 @@ struct StubPage: Page {
/// default) to leave them unversioned.
/// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
/// to omit it.
/// - analytics: the analytics tracker rendered as a deferred script in the document head,
/// or `nil` (the default) to omit it.
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
/// (the default) to omit them.
/// - structuredData: the structured data rendered as a JSON-LD script in the document
@@ -44,10 +49,12 @@ struct StubPage: Page {
locale: Locale = .init(identifier: "en"),
assetVersion: String? = nil,
canonicalURL: String? = nil,
analytics: Analytics? = nil,
socialCard: SocialCard? = nil,
structuredData: StructuredData? = nil,
summary: String? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.canonicalURL = canonicalURL
self.locale = locale