Project updates from Template #1
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
**/.swiftpm
|
**/.swiftpm
|
||||||
|
|
||||||
# Test sources
|
# Test sources
|
||||||
**/Tests
|
**/Tests/DB
|
||||||
|
|
||||||
# Xcode project (not used by the Linux build)
|
# Xcode project (not used by the Linux build)
|
||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 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.
|
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
|
||||||
///
|
///
|
||||||
/// 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
|
/// The behavior flags default to on and render their `data-` attributes only when enabled, since the tracker treats an absent attribute as off.
|
||||||
/// when the page has none. The attribute names follow the Umami tracker convention. The three behavior flags default to on, and each renders its
|
/// Recorder mode defaults to off; when on, the page embeds a second deferred script loading the session recorder from the tracker's origin.
|
||||||
/// `data-` attribute only when enabled, since the tracker treats an absent attribute as off. Recorder mode, by contrast, defaults to off; when
|
|
||||||
/// activated, the page embeds a second deferred script that loads the session recorder from the tracker's origin.
|
|
||||||
public struct Analytics: Sendable {
|
public struct Analytics: Sendable {
|
||||||
|
|
||||||
// MARK: Type aliases
|
|
||||||
|
|
||||||
public typealias Attribute = (name: String, value: String)
|
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
@@ -66,17 +60,12 @@ public struct Analytics: Sendable {
|
|||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// The tracker script's attributes, in a stable order: the website id, the reporting domains when filtered, then each enabled behavior flag.
|
/// The tracker script's attributes: the website id and reporting domains, then each enabled behavior flag; disabled flags are omitted.
|
||||||
///
|
|
||||||
/// A disabled flag is left out entirely, since the tracker treats an absent attribute as off. An empty ``domains`` is left out for the same reason:
|
|
||||||
/// the tracker reads the attribute as an allowlist, so rendering it empty would filter out every host rather than none. 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] {
|
public var attributes: [Attribute] {
|
||||||
var attributes = [(
|
var attributes: [Attribute] = [
|
||||||
name: "data-website-id",
|
.init("data-website-id", value: websiteID),
|
||||||
value: websiteID
|
.init("data-domains", value: domains)
|
||||||
)]
|
]
|
||||||
|
|
||||||
if !domains.isEmpty {
|
if !domains.isEmpty {
|
||||||
attributes.append((
|
attributes.append((
|
||||||
@@ -86,33 +75,22 @@ public struct Analytics: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if excludeHash {
|
if excludeHash {
|
||||||
attributes.append((
|
attributes.append(.init("data-exclude-hash", value: "true"))
|
||||||
name: "data-exclude-hash",
|
|
||||||
value: "true"
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if doNotTrack {
|
if doNotTrack {
|
||||||
attributes.append((
|
attributes.append(.init("data-do-not-track", value: "true"))
|
||||||
name: "data-do-not-track",
|
|
||||||
value: "true"
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if performance {
|
if performance {
|
||||||
attributes.append((
|
attributes.append(.init("data-performance", value: "true"))
|
||||||
name: "data-performance",
|
|
||||||
value: "true"
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return attributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The origin the tracker is served from (scheme and host, plus any explicit port), derived from the ``scriptURL``, or `nil` when the
|
/// The origin the tracker is served from (scheme, host, and any explicit port), derived from the ``scriptURL``; `nil` when the URL
|
||||||
/// URL carries no scheme or host.
|
/// carries no scheme or host. Rendered as a `preconnect` hint before the tracker script.
|
||||||
///
|
|
||||||
/// A page renders it as a `preconnect` hint before the tracker script, so the connection handshake starts as early as possible.
|
|
||||||
public var origin: String? {
|
public var origin: String? {
|
||||||
guard
|
guard
|
||||||
let url = URL(string: scriptURL),
|
let url = URL(string: scriptURL),
|
||||||
@@ -127,10 +105,8 @@ public struct Analytics: Sendable {
|
|||||||
return "\(scheme)://\(host)\(port)"
|
return "\(scheme)://\(host)\(port)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The URL the session recorder script is loaded from, derived from the tracker's ``origin``, or `nil` when ``recorder`` mode is off
|
/// The URL the session recorder script is loaded from; `nil` when ``recorder`` mode is off or no ``origin`` can be derived.
|
||||||
/// or no origin can be derived from the ``scriptURL``.
|
/// Rendered as a second deferred script carrying only the `data-website-id` attribute.
|
||||||
///
|
|
||||||
/// A page renders it as a second deferred script tag after the tracker script, carrying only the `data-website-id` attribute.
|
|
||||||
public var recorderScriptURL: String? {
|
public var recorderScriptURL: String? {
|
||||||
guard recorder, let origin else {
|
guard recorder, let origin else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
extension Analytics {
|
||||||
|
/// A name-value pair rendered as an HTML attribute.
|
||||||
|
///
|
||||||
|
/// The ``name`` is a full `data-` attribute name applied verbatim — except inside ``Analytics/Event/properties``, where it is the bare
|
||||||
|
/// key the event prefixes on render.
|
||||||
|
public struct Attribute: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The attribute's name.
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
/// The attribute's value.
|
||||||
|
public let value: String
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a tracker attribute.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: the attribute's name.
|
||||||
|
/// - value: the attribute's value.
|
||||||
|
public init(
|
||||||
|
_ name: String,
|
||||||
|
value: String
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
extension Analytics {
|
||||||
|
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
|
||||||
|
///
|
||||||
|
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
|
||||||
|
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
|
||||||
|
public struct Event: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The event's name, which is what a report groups by.
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
/// The event's properties, in declaration order, which is the order they render.
|
||||||
|
///
|
||||||
|
/// Each ``Attribute/name`` is the bare key — `set`, not `data-umami-event-set`; ``attributes`` adds the prefix.
|
||||||
|
public let properties: [Attribute]
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates an event.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: the event's name, which is what a report groups by.
|
||||||
|
/// - properties: the event's properties as bare keys — `set`, not `data-umami-event-set` — rendered in the order written.
|
||||||
|
/// Empty by default.
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
properties: KeyValuePairs<String, String> = [:]
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.properties = properties.map {
|
||||||
|
.init($0.key, value: $0.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
|
||||||
|
public var attributes: [Attribute] {
|
||||||
|
[.init(Constant.Name.prefix, value: name)]
|
||||||
|
+ properties.map {
|
||||||
|
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private enum Constant {
|
||||||
|
enum Name {
|
||||||
|
/// The attribute name the tracker watches for, and the prefix each property renders under.
|
||||||
|
static let prefix = "data-umami-event"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"Analytics.Event type",
|
||||||
|
.tags(.type)
|
||||||
|
)
|
||||||
|
struct AnalyticsEventTests {
|
||||||
|
|
||||||
|
// MARK: Computed tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the event name alone when it carries no properties`() {
|
||||||
|
let event = Analytics.Event(name: "instagram")
|
||||||
|
|
||||||
|
#expect(event.attributes.count == 1)
|
||||||
|
#expect(event.attributes[0] == Analytics.Attribute(
|
||||||
|
"data-umami-event",
|
||||||
|
value: "instagram"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders each property as an attribute suffixed by its key`() {
|
||||||
|
let event = Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: ["set": "avc-xi"]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(event.attributes.count == 2)
|
||||||
|
#expect(event.attributes[1] == Analytics.Attribute(
|
||||||
|
"data-umami-event-set",
|
||||||
|
value: "avc-xi"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the name first, then the properties in the order given`() {
|
||||||
|
let event = Analytics.Event(
|
||||||
|
name: "interview",
|
||||||
|
properties: [
|
||||||
|
"placement": "credit",
|
||||||
|
"locale": "en",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// The order is the rendered attribute order, which a dictionary would leave to churn between builds.
|
||||||
|
#expect(event.attributes.map(\.name) == [
|
||||||
|
"data-umami-event",
|
||||||
|
"data-umami-event-placement",
|
||||||
|
"data-umami-event-locale",
|
||||||
|
])
|
||||||
|
#expect(event.attributes.map(\.value) == ["interview", "credit", "en"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Equatable tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `matches an event rendering the same attributes`() {
|
||||||
|
let event = Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: ["set": "avc-xi"]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(event == Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: ["set": "avc-xi"]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: ["set": "avc-ix"]
|
||||||
|
),
|
||||||
|
Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: ["show": "avc-xi"]
|
||||||
|
),
|
||||||
|
Analytics.Event(
|
||||||
|
name: "apple_music",
|
||||||
|
properties: ["set": "avc-xi"]
|
||||||
|
),
|
||||||
|
Analytics.Event(name: "playlist"),
|
||||||
|
Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: [
|
||||||
|
"set": "avc-xi",
|
||||||
|
"placement": "strip",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
])
|
||||||
|
func `differs from an event rendering anything else`(
|
||||||
|
from other: Analytics.Event
|
||||||
|
) {
|
||||||
|
let event = Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: ["set": "avc-xi"]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(event != other)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# ================================
|
# ================================
|
||||||
# Asset image
|
# Asset image
|
||||||
# ================================
|
# ================================
|
||||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS assets
|
FROM node:22-alpine AS assets
|
||||||
|
|
||||||
ARG ESBUILD_VERSION=0.28.1
|
ARG ESBUILD_VERSION=0.28.1
|
||||||
ARG OXIPNG_VERSION=9.1.5
|
ARG OXIPNG_VERSION=9.1.5
|
||||||
@@ -30,7 +30,7 @@ COPY --from=assets /static /
|
|||||||
# ================================
|
# ================================
|
||||||
# Build image
|
# Build image
|
||||||
# ================================
|
# ================================
|
||||||
FROM swift:6.3-noble@sha256:69bf1f0281e13d82c9e49d67c2dd1dcc8c00bad738c860f4323d7078787ec8ea AS build
|
FROM swift:6.3-noble AS build
|
||||||
|
|
||||||
# Install OS updates
|
# Install OS updates
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||||
@@ -75,8 +75,9 @@ RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
|
|||||||
# Switch to the staging area
|
# Switch to the staging area
|
||||||
WORKDIR /staging
|
WORKDIR /staging
|
||||||
|
|
||||||
# Copy main executable to staging area
|
# Copy main executable to staging area, without its debug sections
|
||||||
RUN cp "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/Website" ./
|
RUN cp "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/Website" ./ \
|
||||||
|
&& strip --strip-debug ./Website
|
||||||
|
|
||||||
# Copy static swift backtracer binary to staging area
|
# Copy static swift backtracer binary to staging area
|
||||||
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
||||||
@@ -94,7 +95,7 @@ RUN chmod -R a-w ./Resources
|
|||||||
# ================================
|
# ================================
|
||||||
# Run image
|
# Run image
|
||||||
# ================================
|
# ================================
|
||||||
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea
|
FROM ubuntu:noble
|
||||||
|
|
||||||
# Make sure all system packages are up to date, and install only essential packages.
|
# Make sure all system packages are up to date, and install only essential packages.
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||||
|
|||||||
@@ -120,7 +120,12 @@ let package = Package(
|
|||||||
package: "hummingbird"
|
package: "hummingbird"
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
path: "Tests/App"
|
path: "Tests/App",
|
||||||
|
resources: [
|
||||||
|
// `Static` links to the service's `Resources/Static`, copying it into the test bundle at build
|
||||||
|
// time — the tests must not read the repository tree, which Xcode's test runner is denied.
|
||||||
|
.copy("Static")
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "WebsiteLibraryTests",
|
name: "WebsiteLibraryTests",
|
||||||
|
|||||||
@@ -20,14 +20,18 @@ struct AppTests {
|
|||||||
.js
|
.js
|
||||||
]
|
]
|
||||||
|
|
||||||
// Absolute path to the package's "Resources/Static" folder, derived from this
|
// Absolute path to the copy of the package's "Resources/Static" folder made into the test bundle
|
||||||
// file's location so the static files resolve regardless of the working directory.
|
// at build time — the repository tree itself is off limits to Xcode's test runner.
|
||||||
private let staticFilesPath = URL(fileURLWithPath: #filePath)
|
private let staticFilesPath: String = {
|
||||||
.deletingLastPathComponent() // Tests/App
|
guard let url = Bundle.module.url(
|
||||||
.deletingLastPathComponent() // Tests
|
forResource: "Static",
|
||||||
.deletingLastPathComponent() // package root
|
withExtension: nil
|
||||||
.appendingPathComponent(.Path.staticResources)
|
) else {
|
||||||
.path
|
preconditionFailure("The static files are missing from the test bundle.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.path
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: Functional tests
|
// MARK: Functional tests
|
||||||
|
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../Resources/Static
|
||||||
Reference in New Issue
Block a user