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