Project updates from Template

This commit contains the latest updates from the generic Website template, which move the toolchain to Swift 6.4, gate analytics and structured data behind traits, and rework page routing:

- Updated the Swift toolchain for all the package dependencies and the Website service to version 6.4. (345b305)
- Adapted all the existing middlewares in the Infrastructure package to Swift 6.4. (26008df)
- Moved test resource teardown in the Persistence and the Website library into defer blocks. (27984a9)
- Gated analytics behind a trait and reworked structured data in the Infrastructure package. (c7d4769)
- Enabled the Analytics trait in the Website service. (f6a6bbd)
- Reworked page routing in the Website service. (d1cb2c1)
- Gated the structured data features in the Infrastructure package behind a trait. (f0a8791)
- Enabled the StructuredData trait of the Infrastructure package in the Website service. (bbfdeb3)
- Fixed the asset pin and a build warning in the Dockerfile of the Website service. (6ed0f9b)

The root README change in 345b305 was left out, as this project no longer carries that file. From d1cb2c1, the RootController rework was left out, as the prototype serves a session-aware landing page and routes its other pages through their own controllers; the view-event reporter was appended to the prototype's shared script instead.

The prototype code was adapted to the stricter Swift 6.4 settings: `String(format:)` was swapped for safe formatting, the NoIndex middleware takes a `@concurrent` next handler, and the tests flagged by warnings-as-errors were tightened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
javierandClaude Opus 5.5 committed 2026-10-25 11:55:43 +01:00
1 parent 326299fc35
commit 911424de24
57 files changed
+864 -273

No files matched your search

+23 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -15,6 +15,16 @@ let package = Package(
]
),
],
traits: [
.trait(
name: "Analytics",
description: "Analytics events, their element tagging, and the tracker script pages embed."
),
.trait(
name: "StructuredData",
description: "Schema.org structured data, rendered as a JSON-LD script."
),
],
dependencies: [
.package(
path: "../Localization"
@@ -82,3 +92,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+7 -5
View File
@@ -8,8 +8,8 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `CompressionMiddleware`, `RateLimitMiddleware`, `NotFoundMiddleware` |
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| Link previews | `SocialCard`, its `locale` and the `alternateLocales` of its other language editions, its `Image` and `Style`, and the `Tag` meta tags it derives |
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair |
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to any attribute-bearing HTML or SVG tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
| Structured data | Opt-in `StructuredData` trait: `StructuredData`, its graph types (`Node`, `Property`, `Value`, `Address`, `Organization`), the open `Name` and `Kind` vocabularies, and the site-wide `Organization`/`WebSite` initializer |
| Analytics | Opt-in `Analytics` trait: `Analytics`, the `Event`s a page reports (with `reporting(_:on:)` to tag an element, for a click or a view), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
| Responses | `CachedHTMLResponse` (rendered and gzipped once), `LocalizedHTMLCollectionResponse` (one of those per language) |
| Localization | The `Negotiate` extension resolving a request's language from its `lang` override, path prefix, and `Accept-Language` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
@@ -18,7 +18,7 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
The package holds only what every service can reuse. Anything a service owns is injected, never referenced.
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page. The same holds for request semantics: only the caller knows whether it negotiates the language or pins it by route, so `variesOnAcceptLanguage:` declares whether the responses carry `Vary: Accept-Language`.
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready form — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — which `Page` applies without knowing the vocabulary. A page's `scripts` and the tracker render as `defer`red head tags, the tracker preceded by a `preconnect` to its cross-origin host.
- **Nodes are joined by `@id`, not repetition.** A node another page must point at gets its identifier from a helper rather than a hand-spelled fragment — `organizationID(forSiteURL:)` names the node the site-wide initializer builds, and that initializer's `founder` takes such an identifier back. A service adds the helper for any node it owns, so neither side can drift.
- **Nodes are joined by `@id`, not repetition.** Identifiers come from helpers, never hand-spelled: `organizationID(forSiteURL:)` and `websiteID(forSiteURL:)` name the site-wide nodes, and `Organization.founder` takes one back. A service adds a helper for each node it owns.
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites retroactively — the Website's `*+Defaults` are the pattern. The open schema.org vocabularies work the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, a service adds its own.
- **Method structs.** Single-operation types such as `FingerprintAssets` take lifetime-fixed configuration in `init` and per-call inputs in `callAsFunction`.
- **Wrap upstream, do not restate it.** Where a Hummingbird middleware is almost right, the package delegates to it and adds only the missing decision — `CompressionMiddleware` passes everything to `ResponseCompressionMiddleware` except responses that already name an encoding, which it would otherwise compress twice.
@@ -31,7 +31,7 @@ Sources/
├── Public/ public API
│ ├── Builders/ RouteCollectionBuilder
│ ├── Enumerations/ AssetExtension
│ ├── Extensions/ addController, Analytics.Event tagging, and the request-language negotiation, plus the default header names, rate limits, and header values
│ ├── Extensions/ addController, Analytics.Event tagging and reporting, and the request-language negotiation, plus the default header names, rate limits, and header values
│ ├── Methods/ FingerprintAssets
│ ├── Middlewares/ the seven HTTP middlewares
│ ├── Protocols/ Asset, Page, RouterController
@@ -49,7 +49,9 @@ Tests/
## Testing
Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, `.type` — declared in `Tests/Utils/Extensions/Tag+Constants.swift`, so a run can be sliced by kind. A new suite takes the tag matching its subject, or adds one when none fits.
Run `swift test --traits Analytics,StructuredData` to include the trait-gated suites.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- macOS 15, matching the sibling packages. The services deploy to Linux containers; the packages declare no UI platforms.
- Package dependencies: the local `Localization` package, `elementary`, `hummingbird`, and — for `CompressionMiddleware` and the responses' gzip — `hummingbird-compression` and `compress-nio`.
@@ -1,5 +1,3 @@
import Foundation
/// Hashes bytes with the FNV-1a 64-bit algorithm.
///
/// The hash is stable across processes and platforms, which `Hasher` deliberately is not, so it suits values that must agree between instances and survive
@@ -25,10 +23,9 @@ struct FNV1aHash {
///
/// Reading it does not consume the running hash: more bytes can be combined afterwards.
var digest: String {
String(
format: "%016llx",
hash
)
let hex = String(hash, radix: 16)
return String(repeating: "0", count: 16 - hex.count) + hex
}
// MARK: Functions
@@ -1,3 +1,4 @@
#if Analytics
import Elementary
public extension Analytics.Event {
@@ -11,9 +12,12 @@ public extension Analytics.Event {
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
/// ```
///
/// - Parameter trigger: what reports the event; a click by default.
/// - Returns: one HTML attribute per event attribute, applied verbatim.
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
attributes.map {
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>(
on trigger: Trigger = .click
) -> [HTMLAttribute<Tag>] {
attributes(on: trigger).map {
.custom(
name: $0.name,
value: $0.value
@@ -22,3 +26,4 @@ public extension Analytics.Event {
}
}
#endif
@@ -5,6 +5,8 @@ public extension HTTPField.Name {
static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
static let referrerPolicy = Self("Referrer-Policy")!
/// The `Sec-Fetch-Mode` field name (not provided as a standard `HTTPField.Name`).
static let secFetchMode = Self("Sec-Fetch-Mode")!
/// The `X-Robots-Tag` field name (not provided as a standard `HTTPField.Name`).
static let robotsTag = Self("X-Robots-Tag")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
@@ -0,0 +1,48 @@
#if Analytics
import Elementary
public extension MarkupContent where Self: _Attributed, Tag: MarkupTrait.AllowsAttributes {
// MARK: Methods
/// Tags the element with the event it reports to the analytics tracker.
///
/// ```swift
/// a(.href(url)) { "Listen" }
/// .reporting(.init(name: "playlist"))
/// ```
///
/// - Parameters:
/// - event: the event the element reports.
/// - trigger: what reports the event; a click by default.
/// - Returns: the element, carrying the event's ``Analytics/Event/attributes(on:)``.
func reporting(
_ event: Analytics.Event,
on trigger: Analytics.Event.Trigger = .click
) -> Self {
attributes(contentsOf: event.tagging(on: trigger))
}
}
public extension MarkupContent where Tag: MarkupTrait.AllowsAttributes {
// MARK: Methods
/// Tags the content with the event it reports to the analytics tracker.
///
/// The fallback for content that is not an element itself, mirroring Elementary's `attributes(contentsOf:)`.
/// - Parameters:
/// - event: the event the content reports.
/// - trigger: what reports the event; a click by default.
/// - Returns: the content, carrying the event's ``Analytics/Event/attributes(on:)``.
@_disfavoredOverload
func reporting(
_ event: Analytics.Event,
on trigger: Analytics.Event.Trigger = .click
) -> _AttributedContent<Self> {
attributes(contentsOf: event.tagging(on: trigger))
}
}
#endif
@@ -47,7 +47,7 @@ extension CompressionMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
let response = try await next(
request,
@@ -59,7 +59,7 @@ extension HTTPSRedirectMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
guard
isEnabled,
@@ -60,7 +60,7 @@ extension NotFoundMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
do {
return try await next(request, context)
@@ -57,7 +57,7 @@ extension RateLimitMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
let admission = buckets.admit(
client(
@@ -49,7 +49,7 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
var response: Response
@@ -39,7 +39,7 @@ extension TrailingSlashRedirectMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
let path = request.uri.path
let canonical = canonicalPath(of: path)
@@ -46,7 +46,7 @@ extension VaryMiddleware: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
var response = try await next(
request,
@@ -6,6 +6,8 @@ import Foundation
/// 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, the analytics
/// tracker script, and the metadata followed by the stylesheet links and the deferred script tags in the head, and the content as the body.
///
/// ``analytics`` requires the `Analytics` trait; ``structuredData``, the `StructuredData` trait.
public protocol Page: HTMLDocument, Sendable {
// MARK: Associated types
@@ -18,8 +20,10 @@ public protocol Page: HTMLDocument, Sendable {
// MARK: Properties
#if Analytics
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
var analytics: Analytics? { get }
#endif
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
var assetVersion: String? { get }
@@ -48,8 +52,10 @@ public protocol Page: HTMLDocument, Sendable {
/// to omit them.
var socialCard: SocialCard? { get }
#if StructuredData
/// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it.
var structuredData: StructuredData? { get }
#endif
/// The stylesheets linked in the document head, in order.
var stylesheets: [any Asset] { get }
@@ -94,6 +100,7 @@ public extension Page {
.content("width=device-width, initial-scale=1")
)
#if Analytics
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
if let origin = analytics?.origin {
link(
@@ -101,6 +108,7 @@ public extension Page {
.href(origin)
)
}
#endif
if let summary {
meta(
@@ -128,6 +136,7 @@ public extension Page {
}
}
#if StructuredData
if let structuredData {
script(.custom(
name: "type",
@@ -136,7 +145,9 @@ public extension Page {
HTMLRaw(structuredData.payload)
}
}
#endif
#if Analytics
if let analytics {
script(
.defer,
@@ -160,6 +171,7 @@ public extension Page {
) {}
}
}
#endif
metadata
@@ -184,20 +196,24 @@ public extension Page {
}
}
#if Analytics
/// The analytics tracker is omitted unless the page provides one.
var analytics: Analytics? {
nil
}
#endif
/// The social card is omitted unless the page provides one.
var socialCard: SocialCard? {
nil
}
#if StructuredData
/// The structured data is omitted unless the page provides one.
var structuredData: StructuredData? {
nil
}
#endif
/// The summary is omitted unless the page provides one.
var summary: String? {
@@ -1,3 +1,4 @@
#if Analytics
import Foundation
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
@@ -114,3 +115,4 @@ public struct Analytics: Sendable {
}
}
#endif
@@ -1,3 +1,4 @@
#if Analytics
extension Analytics {
/// A name-value pair rendered as an HTML attribute.
///
@@ -29,3 +30,4 @@ extension Analytics {
}
}
#endif
@@ -1,8 +1,11 @@
#if Analytics
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.
///
/// A ``Trigger/view`` renders under `data-view-event`, which a page script reports instead of the tracker.
public struct Event: Equatable, Sendable {
// MARK: Properties
@@ -34,22 +37,51 @@ extension Analytics {
// MARK: Computed
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
/// The event's attributes when clicked; see ``attributes(on:)``.
public var attributes: [Attribute] {
[.init(Constant.Name.prefix, value: name)]
attributes(on: .click)
}
// MARK: Functions
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
/// - Parameter trigger: what reports the event.
/// - Returns: one attribute for the name, then one per property.
public func attributes(on trigger: Trigger) -> [Attribute] {
[.init(trigger.prefix, value: name)]
+ properties.map {
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
.init("\(trigger.prefix)-\($0.name)", value: $0.value)
}
}
}
}
// MARK: - Constants
// MARK: - Types
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"
extension Analytics.Event {
/// What reports an event.
public enum Trigger: Sendable {
/// A click, which the tracker records itself.
case click
/// The element first coming fully on screen, which a page script records.
case view
}
}
// MARK: - Helpers
private extension Analytics.Event.Trigger {
// MARK: Computed
/// The attribute name marking the event, and the prefix each property renders under.
var prefix: String {
switch self {
case .click: "data-umami-event"
case .view: "data-view-event"
}
}
}
#endif
@@ -45,6 +45,7 @@ extension SocialCard {
Tag(String(width), name: .imageWidth),
Tag(String(height), name: .imageHeight),
alt.map { Tag($0, name: .imageAlt) },
alt.map { Tag($0, name: .twitterImageAlt) },
]
return tags.compactMap { $0 }
@@ -68,6 +68,8 @@ extension SocialCard.Tag {
case title = "og:title"
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
case twitter = "twitter:card"
/// The `twitter:image:alt` tag, carrying the share image's text for assistive technologies.
case twitterImageAlt = "twitter:image:alt"
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
case type = "og:type"
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
@@ -84,7 +86,7 @@ public extension SocialCard.Tag.Name {
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
var attribute: SocialCard.Tag.Attribute {
self == .twitter ? .name : .property
rawValue.hasPrefix("twitter:") ? .name : .property
}
}
@@ -1,8 +1,9 @@
#if StructuredData
/// The structured data of a page, rendered as a JSON-LD script in the document head.
///
/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
/// shape directly, or uses ``init(organization:inLanguage:)`` for the site-wide pair every page shares.
///
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
/// page providing the data.
@@ -37,6 +38,15 @@ public struct StructuredData: Equatable, Sendable {
url + "#organization"
}
/// The `@id` of the `WebSite` node the site-wide initializer builds, for a page node's `isPartOf` to reference.
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
/// - Returns: the website node's `@id`.
public static func websiteID(
forSiteURL url: String
) -> String {
url + "#website"
}
// MARK: Computed
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
@@ -53,75 +63,18 @@ public struct StructuredData: Equatable, Sendable {
public extension StructuredData {
/// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
/// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
/// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
/// A property whose fact the data does not carry is left out.
/// Creates the site-wide structured data: the organization's node and the `WebSite` node it publishes, both with an `@id`.
/// - Parameters:
/// - name: the name of the organization and the site.
/// - url: the absolute URL the site is served at.
/// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property.
/// - description: what the organization does, or `nil` (the default) to omit its property.
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
/// - organization: the organization publishing the site.
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
init(
name: String,
url: String,
alternateName: String? = nil,
description: String? = nil,
areaServed: String? = nil,
email: String? = nil,
logo: String? = nil,
inLanguage: [String] = [],
profiles: [String] = [],
founder: String? = nil
organization: Organization,
inLanguage: [String] = []
) {
let id = Self.organizationID(forSiteURL: url)
var organization: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
]
if let alternateName {
organization.append(.init(.alternateName, value: .string(alternateName)))
}
if let description {
organization.append(.init(.description, value: .string(description)))
}
if let areaServed {
organization.append(.init(.areaServed, value: .string(areaServed)))
}
if let email {
organization.append(.init(.email, value: .string(email)))
}
if let logo {
organization.append(.init(.logo, value:.string(logo)))
}
if !profiles.isEmpty {
organization.append(.init(
.sameAs,
value: .array(profiles.map(Value.string))
))
}
if let founder {
organization.append(.init(.founder, value: .reference(founder)))
}
var website: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
.init(.name, value: .string(organization.name)),
.init(.url, value: .string(organization.url)),
.init(.publisher, value: .reference(Self.organizationID(forSiteURL: organization.url))),
]
if !inLanguage.isEmpty {
@@ -132,13 +85,10 @@ public extension StructuredData {
}
self.init(nodes: [
.init(
type: .organization,
id: id,
properties: organization
),
organization.node,
.init(
type: .website,
id: Self.websiteID(forSiteURL: organization.url),
properties: website
),
])
@@ -159,3 +109,4 @@ private extension StructuredData {
}
}
#endif
@@ -0,0 +1,57 @@
#if StructuredData
extension StructuredData {
/// A schema.org `PostalAddress`.
public struct Address: Equatable, Sendable {
// MARK: Properties
/// The ISO 3166-1 alpha-2 country code (e.g. `NL`).
public let country: String
/// The city.
public let locality: String
/// The postal code.
public let postalCode: String
/// The street and house number.
public let street: String
// MARK: Initializers
/// Creates a postal address.
/// - Parameters:
/// - street: the street and house number.
/// - postalCode: the postal code.
/// - locality: the city.
/// - country: the ISO 3166-1 alpha-2 country code (e.g. `NL`).
public init(
street: String,
postalCode: String,
locality: String,
country: String
) {
self.country = country
self.locality = locality
self.postalCode = postalCode
self.street = street
}
// MARK: Computed
/// The address's node.
var node: Node {
.init(
type: .postalAddress,
properties: [
.init(.streetAddress, value: .string(street)),
.init(.postalCode, value: .string(postalCode)),
.init(.addressLocality, value: .string(locality)),
.init(.addressCountry, value: .string(country)),
]
)
}
}
}
#endif
@@ -1,3 +1,4 @@
#if StructuredData
extension StructuredData {
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
public struct Node: Equatable, Sendable {
@@ -87,6 +88,9 @@ extension StructuredData.Node {
public extension StructuredData.Node.Kind {
/// An organization, e.g. the one publishing a website.
static let organization: Self = "Organization"
/// A postal address.
static let postalAddress: Self = "PostalAddress"
/// A website.
static let website: Self = "WebSite"
}
#endif
@@ -0,0 +1,137 @@
#if StructuredData
extension StructuredData {
/// A schema.org `Organization` publishing a site; a fact it does not carry is left out.
public struct Organization: Equatable, Sendable {
// MARK: Properties
/// The postal address.
public let address: Address?
/// The name it is also known by.
public let alternateName: String?
/// The area it serves.
public let areaServed: String?
/// What it does.
public let description: String?
/// The email address.
public let email: String?
/// The `@id` of the `Person` node founding it.
public let founder: String?
/// The ISO 8601 date it was founded.
public let foundingDate: String?
/// The ISO 6523 identifier.
public let iso6523Code: String?
/// The registered name.
public let legalName: String?
/// The absolute URL of the logo.
public let logo: String?
/// The name, shared by the site.
public let name: String
/// The absolute URLs of its public profiles.
public let profiles: [String]
/// The phone number.
public let telephone: String?
/// The absolute URL of the site.
public let url: String
/// The VAT number.
public let vatID: String?
// MARK: Initializers
/// Creates an organization.
/// - Parameters:
/// - name: the name, shared by the site.
/// - url: the absolute URL of the site.
/// - alternateName: the name it is also known by.
/// - legalName: the registered name.
/// - description: what it does.
/// - areaServed: the area it serves.
/// - email: the email address.
/// - telephone: the phone number.
/// - address: the postal address.
/// - vatID: the VAT number.
/// - iso6523Code: the ISO 6523 identifier.
/// - logo: the absolute URL of the logo.
/// - profiles: the absolute URLs of its public profiles.
/// - foundingDate: the ISO 8601 date it was founded.
/// - founder: the `@id` of the `Person` node founding it.
public init(
name: String,
url: String,
alternateName: String? = nil,
legalName: String? = nil,
description: String? = nil,
areaServed: String? = nil,
email: String? = nil,
telephone: String? = nil,
address: Address? = nil,
vatID: String? = nil,
iso6523Code: String? = nil,
logo: String? = nil,
profiles: [String] = [],
foundingDate: String? = nil,
founder: String? = nil
) {
self.address = address
self.alternateName = alternateName
self.areaServed = areaServed
self.description = description
self.email = email
self.founder = founder
self.foundingDate = foundingDate
self.iso6523Code = iso6523Code
self.legalName = legalName
self.logo = logo
self.name = name
self.profiles = profiles
self.telephone = telephone
self.url = url
self.vatID = vatID
}
// MARK: Computed
/// The organization's node.
var node: Node {
let properties: [Property?] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
alternateName.map { .init(.alternateName, value: .string($0)) },
legalName.map { .init(.legalName, value: .string($0)) },
description.map { .init(.description, value: .string($0)) },
areaServed.map { .init(.areaServed, value: .string($0)) },
email.map { .init(.email, value: .string($0)) },
telephone.map { .init(.telephone, value: .string($0)) },
address.map { .init(.address, value: .node($0.node)) },
vatID.map { .init(.vatID, value: .string($0)) },
iso6523Code.map { .init(.iso6523Code, value: .string($0)) },
logo.map { .init(.logo, value: .string($0)) },
profiles.isEmpty ? nil : .init(.sameAs, value: .array(profiles.map(Value.string))),
foundingDate.map { .init(.foundingDate, value: .string($0)) },
founder.map { .init(.founder, value: .reference($0)) },
]
return .init(
type: .organization,
id: StructuredData.organizationID(forSiteURL: url),
properties: properties.compactMap { $0 }
)
}
}
}
#endif
@@ -1,3 +1,4 @@
#if StructuredData
extension StructuredData {
/// A named property of a ``Node``, in the position it renders.
public struct Property: Equatable, Sendable {
@@ -69,6 +70,12 @@ extension StructuredData.Property {
// MARK: - Constants
public extension StructuredData.Property.Name {
/// The postal address of the thing a node describes.
static let address: Self = "address"
/// The country of a postal address.
static let addressCountry: Self = "addressCountry"
/// The city of a postal address.
static let addressLocality: Self = "addressLocality"
/// The name the thing a node describes is also known by.
static let alternateName: Self = "alternateName"
/// The area an organization serves.
@@ -79,16 +86,31 @@ public extension StructuredData.Property.Name {
static let email: Self = "email"
/// The person who founded an organization.
static let founder: Self = "founder"
/// The ISO 8601 date an organization was founded.
static let foundingDate: Self = "foundingDate"
/// The language a creative work — a site, a page — is published in.
static let inLanguage: Self = "inLanguage"
/// An organization's ISO 6523 identifier.
static let iso6523Code: Self = "iso6523Code"
/// An organization's registered name.
static let legalName: Self = "legalName"
/// The absolute URL of an organization's logo.
static let logo: Self = "logo"
/// The name of the thing a node describes.
static let name: Self = "name"
/// The postal code of a postal address.
static let postalCode: Self = "postalCode"
/// The organization publishing a website.
static let publisher: Self = "publisher"
/// The absolute URLs of the profiles that also identify the thing a node describes.
static let sameAs: Self = "sameAs"
/// The street and house number of a postal address.
static let streetAddress: Self = "streetAddress"
/// The phone number of the thing a node describes.
static let telephone: Self = "telephone"
/// The absolute URL of the thing a node describes.
static let url: Self = "url"
/// An organization's VAT number.
static let vatID: Self = "vatID"
}
#endif
@@ -1,3 +1,4 @@
#if StructuredData
extension StructuredData {
/// A value of a ``Property``: a string, a number, a list, a nested node, or a reference to another node.
///
@@ -69,3 +70,4 @@ extension StructuredData.Value {
}
}
#endif
@@ -1,3 +1,4 @@
#if Analytics
import Elementary
import Testing
@@ -57,3 +58,4 @@ struct AnalyticsEventTaggingTests {
}
}
#endif
@@ -0,0 +1,68 @@
#if Analytics
import Elementary
import Testing
@testable import Infrastructure
@Suite(
"MarkupContent+Reporting extension",
.tags(.extension)
)
struct MarkupContentReportingTests {
// MARK: Methods tests
@Test
func `renders the same markup as applying the event's tagging`() {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
let reported = a(.href("/")) { "Listen" }
.reporting(event)
.render()
let tagged = a(.href("/")) { "Listen" }
.attributes(contentsOf: event.tagging())
.render()
#expect(reported == tagged)
#expect(reported == #"<a href="/" data-umami-event="playlist" data-umami-event-set="avc-xi">Listen</a>"#)
}
@Test
func `keeps the attributes chained before it`() {
let rendered = a(.href("https://example.com")) { "Listen" }
.attributes(.target(.blank), when: true)
.reporting(.init(name: "playlist"))
.render()
#expect(rendered == #"<a href="https://example.com" target="_blank" data-umami-event="playlist">Listen</a>"#)
}
@Test
func `tags a view under its own prefix`() {
let rendered = h2 { "KLM" }
.reporting(
.init(
name: "case_view",
properties: ["case": "klm"]
),
on: .view
)
.render()
#expect(rendered == #"<h2 data-view-event="case_view" data-view-event-case="klm">KLM</h2>"#)
}
@Test
func `reports from an SVG tag`() {
let rendered = SVG.path {}
.reporting(.init(name: "logo"))
.render()
#expect(rendered.contains(#"data-umami-event="logo""#))
}
}
#endif
@@ -101,6 +101,7 @@ struct PageTests {
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
}
#if StructuredData
@Test
func `omits the structured data script by default`() {
let html = StubPage().render()
@@ -111,21 +112,25 @@ struct PageTests {
@Test
func `renders the structured data script when provided`() {
let html = StubPage(structuredData: .init(
name: "Stub Site",
url: "https://stub.example/",
logo: "https://stub.example/logo.png",
profiles: ["https://social.example/stub"]
organization: .init(
name: "Stub Site",
url: "https://stub.example/",
logo: "https://stub.example/logo.png",
profiles: ["https://social.example/stub"]
)
)).render()
#expect(html.contains(
#"<script type="application/ld+json">"# +
#"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://stub.example/#organization","name":"Stub Site","url":"https://stub.example/","logo":"https://stub.example/logo.png","sameAs":["https://social.example/stub"]},"# +
#"{"@type":"WebSite","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
#"{"@type":"WebSite","@id":"https://stub.example/#website","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
#"</script>"#
))
}
#endif
#if Analytics
@Test
func `omits the analytics tracker by default`() {
let html = StubPage().render()
@@ -194,6 +199,7 @@ struct PageTests {
#expect(!html.contains("data-do-not-track"))
#expect(!html.contains("data-performance"))
}
#endif
@Test
func `appends the version token to the asset URLs`() {
@@ -1,3 +1,4 @@
#if Analytics
import Testing
@testable import Infrastructure
@@ -35,6 +36,21 @@ struct AnalyticsEventTests {
))
}
/// The tracker ignores `data-view-event`, so a view is never recorded as a click.
@Test
func `renders a view under its own prefix`() {
let event = Analytics.Event(
name: "case_view",
properties: ["case": "klm"]
)
#expect(event.attributes(on: .view) == [
.init("data-view-event", value: "case_view"),
.init("data-view-event-case", value: "klm"),
])
#expect(event.attributes == event.attributes(on: .click))
}
@Test
func `renders the name first, then the properties in the order given`() {
let event = Analytics.Event(
@@ -103,3 +119,4 @@ struct AnalyticsEventTests {
}
}
#endif
@@ -1,3 +1,4 @@
#if Analytics
import Testing
@testable import Infrastructure
@@ -133,3 +134,4 @@ struct AnalyticsTests {
}
}
#endif
@@ -40,6 +40,7 @@ struct SocialCardTests {
.init("2400", name: .imageWidth),
.init("1260", name: .imageHeight),
.init("An image.", name: .imageAlt),
.init("An image.", name: .twitterImageAlt),
.init("summary_large_image", name: .twitter),
])
}
@@ -70,6 +71,7 @@ struct SocialCardTests {
#expect(names.contains(.image))
#expect(!names.contains(.imageAlt))
#expect(!names.contains(.twitterImageAlt))
}
@Test
@@ -87,6 +89,7 @@ struct SocialCardTests {
@Test
func `keys a tag by the attribute its name dictates`() {
#expect(SocialCard.Tag.Name.twitter.attribute == .name)
#expect(SocialCard.Tag.Name.twitterImageAlt.attribute == .name)
#expect(SocialCard.Tag.Name.title.attribute == .property)
#expect(SocialCard.Tag("website", name: .type).attribute == .property)
}
@@ -1,3 +1,4 @@
#if StructuredData
import Foundation
import Testing
@@ -14,46 +15,80 @@ struct StructuredDataTests {
@Test
func `derives the full payload from complete data`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/",
logo: "https://site.example/logo.png",
inLanguage: ["en", "nl"],
profiles: [
"https://social.example/a-site",
"https://videos.example/a-site",
]
organization: .init(
name: "A Site",
url: "https://site.example/",
logo: "https://site.example/logo.png",
profiles: [
"https://social.example/a-site",
"https://videos.example/a-site",
]
),
inLanguage: ["en", "nl"]
)
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
#""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
#"{"@type":"WebSite","@id":"https://site.example/#website","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
)
}
@Test
func `carries the organization's alternate name, description, area, and address in that order`() {
func `carries the organization's optional facts in order`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/",
alternateName: "The Site",
description: "What the site is.",
areaServed: "A City",
email: "hello@site.example"
organization: .init(
name: "A Site",
url: "https://site.example/",
alternateName: "The Site",
legalName: "A Site B.V.",
description: "What the site is.",
areaServed: "A City",
email: "hello@site.example",
telephone: "+31000000000",
address: .init(
street: "A Street 1",
postalCode: "1000 AA",
locality: "A City",
country: "NL"
),
vatID: "NL000000000B01",
iso6523Code: "0106:00000000",
foundingDate: "2000"
)
)
#expect(data.payload.contains(
#""name":"A Site","url":"https://site.example/","alternateName":"The Site","description":"What the site is.","areaServed":"A City","email":"hello@site.example""#
#""name":"A Site","url":"https://site.example/","alternateName":"The Site","legalName":"A Site B.V.","description":"What the site is.","# +
#""areaServed":"A City","email":"hello@site.example","telephone":"+31000000000","# +
#""address":{"@type":"PostalAddress","streetAddress":"A Street 1","postalCode":"1000 AA","addressLocality":"A City","addressCountry":"NL"},"# +
#""vatID":"NL000000000B01","iso6523Code":"0106:00000000","foundingDate":"2000"}"#
))
}
@Test
func `omits the optional facts when the data carries none`() {
let payload = StructuredData(
organization: .init(
name: "A Site",
url: "https://site.example/"
)
).payload
for name in ["legalName", "telephone", "address", "vatID", "iso6523Code", "foundingDate"] {
#expect(!payload.contains(name))
}
}
@Test
func `references the organization's founder by identifier`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/",
profiles: ["https://social.example/a-site"],
founder: "https://site.example/who#person"
organization: .init(
name: "A Site",
url: "https://site.example/",
profiles: ["https://social.example/a-site"],
founder: "https://site.example/who#person"
)
)
// Last of the organization's properties, after the profiles.
@@ -65,8 +100,10 @@ struct StructuredDataTests {
@Test
func `omits the founder when the data carries none`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/"
organization: .init(
name: "A Site",
url: "https://site.example/"
)
)
#expect(!data.payload.contains("founder"))
@@ -76,8 +113,10 @@ struct StructuredDataTests {
func `derives the organization's identifier from the site URL`() {
let url = "https://site.example/"
let data = StructuredData(
name: "A Site",
url: url
organization: .init(
name: "A Site",
url: url
)
)
// Another page references the organization by this, so it must name the node the initializer actually builds.
@@ -85,16 +124,33 @@ struct StructuredDataTests {
#expect(data.payload.contains(##""@id":"\##(StructuredData.organizationID(forSiteURL: url))""##))
}
@Test
func `derives the website's identifier from the site URL`() {
let url = "https://site.example/"
let data = StructuredData(
organization: .init(
name: "A Site",
url: url
)
)
// A page's own node references the website by this, so it must name the node the initializer actually builds.
#expect(StructuredData.websiteID(forSiteURL: url) == "https://site.example/#website")
#expect(data.payload.contains(##""@type":"WebSite","@id":"\##(StructuredData.websiteID(forSiteURL: url))""##))
}
@Test
func `omits the properties of the facts minimal data does not carry`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/"
organization: .init(
name: "A Site",
url: "https://site.example/"
)
)
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# +
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
#"{"@type":"WebSite","@id":"https://site.example/#website","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
)
}
@@ -125,8 +181,10 @@ struct StructuredDataTests {
@Test
func `escapes the values it embeds in the payload`() {
let data = StructuredData(
name: #"A "Quoted" \ Site"#,
url: "https://site.example/</script>"
organization: .init(
name: #"A "Quoted" \ Site"#,
url: "https://site.example/</script>"
)
)
#expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#))
@@ -199,10 +257,12 @@ struct StructuredDataTests {
func `derives a payload that parses back to the facts it carries`() throws {
let name = "A \"Site\"\nwith \\ every <hazard>"
let data = StructuredData(
name: name,
url: "https://site.example/</script>",
logo: "https://site.example/logo.png",
profiles: ["https://social.example/a-site"]
organization: .init(
name: name,
url: "https://site.example/</script>",
logo: "https://site.example/logo.png",
profiles: ["https://social.example/a-site"]
)
)
let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
@@ -215,3 +275,4 @@ struct StructuredDataTests {
}
}
#endif
@@ -7,8 +7,10 @@ struct StubPage: Page {
// MARK: Properties
#if Analytics
/// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it.
let analytics: Analytics?
var analytics: Analytics? = nil
#endif
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
@@ -22,8 +24,10 @@ struct StubPage: Page {
/// The card rendered as link-preview tags in the document head, or `nil` to omit them.
let socialCard: SocialCard?
#if StructuredData
/// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it.
let structuredData: StructuredData?
var structuredData: StructuredData? = nil
#endif
/// The summary rendered in the document head, or `nil` to omit it.
let summary: String?
@@ -37,29 +41,21 @@ 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
/// head, or `nil` (the default) to omit it.
/// - summary: the summary rendered in the document head, or `nil` (the default)
/// to omit it.
init(
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
self.socialCard = socialCard
self.structuredData = structuredData
self.summary = summary
}
@@ -99,3 +95,37 @@ struct StubPage: Page {
}
}
#if Analytics
// MARK: - Analytics
extension StubPage {
// MARK: Initializers
/// Creates a stub page embedding the given analytics tracker.
/// - Parameter analytics: the analytics tracker rendered as a deferred script in the document head.
init(analytics: Analytics) {
self.init()
self.analytics = analytics
}
}
#endif
#if StructuredData
// MARK: - Structured data
extension StubPage {
// MARK: Initializers
/// Creates a stub page with the given structured data.
/// - Parameter structuredData: the structured data to render.
init(structuredData: StructuredData) {
self.init()
self.structuredData = structuredData
}
}
#endif
+13 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -35,3 +35,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+1 -1
View File
@@ -38,6 +38,6 @@ Tests/
Every suite carries a tag for the kind of API it exercises — `.method` or `.type`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- No package dependencies — Foundation and Synchronization only.
+13 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -97,3 +97,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+1 -1
View File
@@ -58,6 +58,6 @@ Outside the application's service group, a built `Fluent` service must be shut d
Every suite carries a tag for the kind of API it exercises — `.enumeration` or `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- Package dependencies: `hummingbird-fluent`, `fluent-postgres-driver`, `fluent-sqlite-driver`, `sql-kit`, `postgres-nio`, and `swift-nio-ssl`; the test target additionally depends on `swift-nio` for the TLS fallback tests' fake server.
@@ -136,7 +136,7 @@ public extension Listing {
/// The grade or condition in a few words: `CGC 9.8` for a slab, `Raw · NM` for a raw issue, the condition alone for a collected edition.
var gradeLabel: String {
if let grader, let grade {
return "\(grader) \(String(format: "%.1f", grade))"
return "\(grader) \(grade.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en"))))"
}
return format == "single-issues" ? "Raw · \(condition)" : condition
@@ -19,14 +19,18 @@ struct TLSTests {
// With TLS disabled the client skips the `SSLRequest` and sends its startup message directly,
// which the fake server answers in plaintext.
let server = try await PlaintextPostgresServer.start()
defer {
try? await server.stop()
}
let connection = try await connect(to: server, tls: .off)
let isConnected = !connection.isClosed
defer {
try? await connection.close()
}
try await connection.close()
try await server.stop()
#expect(isConnected)
#expect(!connection.isClosed)
}
@Test
@@ -34,14 +38,18 @@ struct TLSTests {
// The fake server refuses the `SSLRequest`, so this connection can only succeed by downgrading
// to plaintext — pinning the driver behavior the `prefer` posture relies on.
let server = try await PlaintextPostgresServer.start()
defer {
try? await server.stop()
}
let connection = try await connect(to: server, tls: .prefer)
let isConnected = !connection.isClosed
defer {
try? await connection.close()
}
try await connection.close()
try await server.stop()
#expect(isConnected)
#expect(!connection.isClosed)
}
@Test
@@ -64,6 +64,11 @@ struct ProbeTests {
// ever be resolved by its deadline — without one, it would wait out the driver's own connect
// timeout (10 seconds) instead.
let server = try await SilentPostgresServer.start()
defer {
try? await server.stop()
}
let service = try Service(
driver: .postgres(
.init(
@@ -91,7 +96,6 @@ struct ProbeTests {
let elapsed = clock.now - start
try await fluent.shutdown()
try await server.stop()
#expect(!isReachable)
// Well past the 100-millisecond deadline to absorb scheduling noise, yet far below the driver's
@@ -69,17 +69,13 @@ struct ServiceTests {
let fluent = service()
do {
let database = try #require(fluent.db() as? any SQLDatabase)
try await database.raw("SELECT 1").run()
} catch {
defer {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
let database = try #require(fluent.db() as? any SQLDatabase)
try await database.raw("SELECT 1").run()
}
@Test("in-memory: migrate, seed, read back")
@@ -124,25 +120,21 @@ private extension ServiceTests {
let fluent = service()
do {
await prepareDB(for: fluent)
try await fluent.migrate()
let repository = CatalogueRepository(fluent: fluent)
#expect(try await !repository.listings().isEmpty)
if revertAfter {
try await fluent.revert()
}
} catch {
defer {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
await prepareDB(for: fluent)
try await fluent.migrate()
let repository = CatalogueRepository(fluent: fluent)
#expect(try await !repository.listings().isEmpty)
if revertAfter {
try await fluent.revert()
}
}
}
+13 -1
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -29,3 +29,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+1 -1
View File
@@ -25,6 +25,6 @@ Tests/
Every suite carries a tag for the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
- No package dependencies — Foundation only.
+12 -12
View File
@@ -4,12 +4,11 @@
FROM node:22-alpine AS assets
ARG ESBUILD_VERSION=0.28.1
ARG OXIPNG_VERSION=9.1.5
ARG OXIPNG_VERSION=9.1
ARG SVGO_VERSION=4.0.2
# Install the minifiers in their own layer, so they are cached across asset changes.
# The oxipng pin is fuzzy (=~) so Alpine package revision bumps (-r0, -r1, ...) do not break the build when the base
# image advances.
# The oxipng pin stops at major.minor: Alpine drops a package's old builds, so a patch pin fails on its next release.
RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \
&& npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}"
@@ -30,7 +29,7 @@ COPY --from=assets /static /
# ================================
# Build image
# ================================
FROM swift:6.3-noble AS build
FROM swift:6.4-noble AS build
# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
@@ -66,12 +65,13 @@ COPY ./Services/Website/Sources ./Services/Website/Sources
# Build the application, with optimizations, with static linking, and using jemalloc.
#
# SPM validates the path of every target in the root package — including the test targets — even when only the
# executable product is built, so those directories have to exist. They are created empty instead of copied: the test
# sources are not needed to compile the product, keeping them out means editing a test never invalidates this layer,
# and it keeps the local database bind mount under Tests/DB out of the image entirely.
RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
&& swift build --package-path ./Services/Website -c release \
# SPM validates every target path, test targets and their resources included, so those directories must exist. They are
# created empty, not copied, so an edited test does not invalidate this layer and the Tests/DB bind mount stays out.
#
# The native build system works around https://github.com/swiftlang/swift-package-manager/issues/10592: Swift 6.4's
# default one fails to link a static Foundation.
RUN mkdir -p ./Services/Website/Tests/App/Static ./Services/Website/Tests/Library \
&& swift build --package-path ./Services/Website -c release --build-system native \
--product "Website" \
--static-swift-stdlib \
-Xlinker -ljemalloc
@@ -80,14 +80,14 @@ RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
WORKDIR /staging
# 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 --build-system native --show-bin-path)/Website" ./ \
&& strip --strip-debug ./Website
# Copy static swift backtracer binary to staging area
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build/Services/Website -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
RUN find -L "$(swift build --package-path /build/Services/Website -c release --build-system native --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
# Create the static files directory (served by FileMiddleware) and fill it with the minified copies from the assets stage
RUN mkdir -p ./Resources/Static
+18 -2
View File
@@ -1,4 +1,4 @@
// swift-tools-version: 6.3
// swift-tools-version: 6.4
import PackageDescription
@@ -21,7 +21,11 @@ let package = Package(
],
dependencies: [
.package(
path: "../../Packages/Infrastructure"
path: "../../Packages/Infrastructure",
traits: [
"Analytics",
"StructuredData",
]
),
.package(
path: "../../Packages/Localization"
@@ -166,3 +170,15 @@ let package = Package(
),
]
)
// Applied to every target: strict memory safety and warnings as errors as guardrails — deprecations stay warnings, so a
// dependency update cannot break the build — plus the upcoming concurrency features Swift 7 turns on by default.
for target in package.targets {
target.swiftSettings = (target.swiftSettings ?? []) + [
.strictMemorySafety(),
.treatAllWarnings(as: .error),
.treatWarning("DeprecatedDeclaration", as: .warning),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances"),
]
}
+3 -3
View File
@@ -21,7 +21,7 @@ The service:
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a PostgreSQL server, selected by a single configuration key.
## Requirements
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
- Swift 6.4 toolchain (`swift-tools-version:6.4`).
- Docker (optional) for the containerized run/deploy workflow.
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
@@ -139,7 +139,7 @@ Four of those are page-authored and optional. `IndexPage` supplies `canonicalURL
| `summary` | `<meta name="description">` | The page's one-line description. |
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). |
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. Requires Infrastructure's `StructuredData` trait, which `Package.swift` enables. |
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
@@ -394,7 +394,7 @@ The test sources are deliberately absent from the image. SPM validates the path
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
```sh
docker buildx imagetools inspect swift:6.3-noble --format '{{.Manifest.Digest}}'
docker buildx imagetools inspect swift:6.4-noble --format '{{.Manifest.Digest}}'
```
Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.
@@ -40,3 +40,32 @@ if (listingForm) {
listingForm.addEventListener("change", apply);
apply();
}
// Elements tagged `data-view-event` are reported the first time they are fully on screen, when the tracker is present.
(() => {
const elements = document.querySelectorAll("[data-view-event]");
if (!window.umami || elements.length === 0) {
return;
}
const prefix = "data-view-event-";
// Each `data-view-event-<name>` attribute is a property, as `data-umami-event-<name>` is for a click.
const properties = (element) => Object.fromEntries(
[...element.attributes]
.filter((attribute) => attribute.name.startsWith(prefix))
.map((attribute) => [attribute.name.slice(prefix.length), attribute.value])
);
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
observer.unobserve(entry.target);
window.umami.track(entry.target.getAttribute("data-view-event"), properties(entry.target));
}
}
}, { threshold: 1 });
elements.forEach((element) => observer.observe(element));
})();
@@ -1,14 +1,14 @@
import Foundation
extension Int {
// MARK: Computed
/// The amount of euro cents as a price: whole euros bare (`€240`), anything else to the cent (`€12.50`).
var euros: String {
isMultiple(of: 100)
let cents = self % 100
return isMultiple(of: 100)
? "€\(self / 100)"
: String(format: "€%d.%02d", self / 100, self % 100)
: "€\(self / 100).\(cents < 10 ? "0" : "")\(cents)"
}
}
@@ -72,7 +72,7 @@ extension Shop {
/// The shop's standing in a line: where it is, since when, and how it is rated.
var standing: String {
"\(location) · selling on CCN since \(sinceYear) · \(String(format: "%.1f", rating)) from \(ratedSales) rated sales"
"\(location) · selling on CCN since \(sinceYear) · \(rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))) from \(ratedSales) rated sales"
}
}
@@ -83,7 +83,7 @@ extension Double {
/// The grade to one decimal place, e.g. `9.8`.
var gradeLabel: String {
String(format: "%.1f", self)
formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))
}
}
@@ -46,6 +46,8 @@ extension Page {
siteMetadata
}
// MARK: Methods
/// The `hreflang` alternates tying the page's language editions together, or nothing without an origin — the annotations require absolute URLs.
///
/// Nothing is emitted for a single-language site either: a set naming one edition tells a search engine nothing it cannot already see.
@@ -91,6 +93,17 @@ extension Page {
}
}
/// The locales of the page's other language editions, in ``ogLocale``'s form.
/// - Parameter languages: the languages the page is published in; every catalog language by default.
/// - Returns: one locale per other language; empty for a single-language page.
func ogAlternateLocales(
languages: [Language] = Language.all
) -> [String] {
languages
.filter { $0.identifier != lang }
.map { Self.ogLocales[$0.identifier] ?? $0.identifier }
}
/// The ``preloadedFonts`` links, then the icon, manifest, and theme colour metadata shared by every page of the website.
///
/// A preload URL must match the stylesheet's `@font-face` source exactly — unversioned, with `crossorigin` — or the browser fetches the
@@ -179,6 +179,7 @@ private extension ComparePage {
of copy: Listing
) -> [(term: String, value: String)] {
let customs = copy.shop.shipsFromEU ? "" : " · customs may apply"
let rating = copy.shop.rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))
return [
("Asking", copy.priceCents.euros + (copy.acceptsOffers ? " · accepts offers" : "")),
@@ -187,7 +188,7 @@ private extension ComparePage {
("Declared defects", copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")),
("Ship to NL", "\(copy.shippingCents.euros) tracked · \(copy.shop.handlingDays)-day handling\(customs)"),
("Shop", "\(copy.shop.name) · \(copy.shop.location)"),
("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(String(format: "%.1f", copy.shop.rating)) · \(copy.shop.returnsNote)"),
("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(rating) · \(copy.shop.returnsNote)"),
]
}
@@ -112,7 +112,7 @@ extension SellerDashboardPage: Page {
tile("\(offers.count)", "Open offers", offers.first.map { "next: \($0.expiresAt.countdown(from: now))" })
tile("\(viewer?.unreadMessages ?? 0)", "Unread conversations", nil)
tile("\(sales.count)", "Sales", "\(sales.count { $0.next == .sent }) awaiting your dispatch")
tile(String(format: "%.1f", shop.rating), "Rating", "\(shop.ratedSales) rated sales")
tile(shop.rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en"))), "Rating", "\(shop.ratedSales) rated sales")
}
section(.class("card sell__panel")) {
div(.class("section-head")) {
@@ -22,7 +22,7 @@ public struct NoIndexMiddleware<Context: RequestContext>: RouterMiddleware {
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
next: @concurrent (Request, Context) async throws -> Response
) async throws -> Response {
do {
var response = try await next(request, context)
@@ -26,6 +26,17 @@ struct PageDefaultsTests {
// MARK: Method tests
@Test
func `lists no alternate social card locales for a single language`() {
#expect(StubPage().ogAlternateLocales(languages: [.default]).isEmpty)
}
@Test
func `lists the social card locale of every other language`() {
#expect(StubPage().ogAlternateLocales(languages: Self.languages) == ["nl"])
#expect(StubPage(locale: .init(identifier: "nl")).ogAlternateLocales(languages: Self.languages) == ["en_US"])
}
@Test
func `renders no language alternates without an origin`() {
let html = StubPage().languageAlternates(
@@ -119,24 +119,22 @@ struct CollectorPagesTests {
}
@Test
func `series lists an issue's copies cheapest delivered first, ready to compare`() {
func `series lists an issue's copies cheapest delivered first, ready to compare`() throws {
let html = SeriesPage(
series: Self.nightward,
listings: [.stub(), .stub(slug: "cheap", priceCents: 9_000, defects: ["Foxing / staining"])],
issue: 1,
viewer: .init(displayName: "Mara")
).render()
let cheap = try? #require(html.range(of: #"value="cheap""#))
let dear = try? #require(html.range(of: #"value="nightward-1""#))
let cheap = try #require(html.range(of: #"value="cheap""#))
let dear = try #require(html.range(of: #"value="nightward-1""#))
#expect(html.contains("Copies of #1 — 2 listed"))
#expect(html.contains(#"action="/compare" method="get""#))
#expect(html.contains("Foxing / staining"))
#expect(html.contains(#"href="/wants/new?title=Nightward%20%232">None listed · alert me"#))
if let cheap, let dear {
#expect(cheap.lowerBound < dear.lowerBound)
}
#expect(cheap.lowerBound < dear.lowerBound)
}
@Test
@@ -153,23 +151,21 @@ struct CollectorPagesTests {
// MARK: ComparePage tests
@Test
func `comparison orders copies by what they cost delivered, and flags a customs border`() {
func `comparison orders copies by what they cost delivered, and flags a customs border`() throws {
let abroad = Shop(
slug: "longbox", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024, isVerified: false,
rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4, shipsNote: "", returnsNote: "returns by agreement", about: ""
)
let html = ComparePage(copies: [.stub(), .stub(slug: "abroad", shop: abroad, priceCents: 15_000)]).render()
let first = try? #require(html.range(of: "€156"))
let second = try? #require(html.range(of: "€246"))
let first = try #require(html.range(of: "€156"))
let second = try #require(html.range(of: "€246"))
#expect(html.contains("customs may apply"))
#expect(html.components(separatedBy: "customs may apply").count - 1 == 1)
#expect(html.contains(#"href="/sign-in?return=/listing/abroad">Buy now"#))
#expect(html.contains("58 · 4.6 · returns by agreement"))
if let first, let second {
#expect(first.lowerBound < second.lowerBound)
}
#expect(first.lowerBound < second.lowerBound)
}
@Test
@@ -43,29 +43,25 @@ struct HealthControllerTests {
)
let fluent = service()
do {
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ready"}"#)
#expect(response.headers[.robotsTag] == "noindex")
}
}
} catch {
defer {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .ok)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"ready"}"#)
#expect(response.headers[.robotsTag] == "noindex")
}
}
}
@Test
@@ -89,29 +85,25 @@ struct HealthControllerTests {
)
let fluent = service()
do {
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .serviceUnavailable)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"unavailable"}"#)
#expect(response.headers[.robotsTag] == "noindex")
}
}
} catch {
defer {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
try await app(
probe: Probe(fluent: fluent)
).test(.router) { client in
try await client.execute(
uri: "/health/ready",
method: .get
) { response in
let body = String(buffer: response.body)
#expect(response.status == .serviceUnavailable)
#expect(response.headers[.contentType] == "application/json")
#expect(body == #"{"status":"unavailable"}"#)
#expect(response.headers[.robotsTag] == "noindex")
}
}
}
@Test
@@ -41,8 +41,8 @@ struct PhotoStoreTests {
])
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
file: ByteBuffer
) throws {
try Self.withFolder { folder in
) {
Self.withFolder { folder in
#expect(throws: PhotoStore.Refusal.notAnImage) {
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
}
@@ -51,8 +51,8 @@ struct PhotoStoreTests {
}
@Test
func `turns down too many photos, and one too large`() throws {
try Self.withFolder { folder in
func `turns down too many photos, and one too large`() {
Self.withFolder { folder in
let store = PhotoStore(staticFilesPath: folder)
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))