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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user