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
}
}