diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
index 30c25e1..e925ac3 100644
--- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
+++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
@@ -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 `` 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
diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift
new file mode 100644
index 0000000..4df4997
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift
@@ -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 `"#))
+ }
+
+ @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(#""#))
+ #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()
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift
new file mode 100644
index 0000000..1ee9a42
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift
@@ -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",
+ ])
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
index ffc755e..d719f1d 100644
--- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
+++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
@@ -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