From 911424de24c719411fa4ca5a3aa405946913a227 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 25 Oct 2026 11:55:43 +0100 Subject: [PATCH] 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 --- Packages/Infrastructure/Package.swift | 24 ++- Packages/Infrastructure/README.md | 12 +- .../Sources/Internal/Types/FNV1aHash.swift | 9 +- .../Extensions/AnalyticsEvent+Tagging.swift | 9 +- .../Extensions/HTTPFieldName+Constants.swift | 2 + .../Extensions/MarkupContent+Reporting.swift | 48 ++++++ .../Middlewares/CompressionMiddleware.swift | 2 +- .../Middlewares/HTTPSRedirectMiddleware.swift | 2 +- .../Middlewares/NotFoundMiddleware.swift | 2 +- .../Middlewares/RateLimitMiddleware.swift | 2 +- .../SecurityHeadersMiddleware.swift | 2 +- .../TrailingSlashRedirectMiddleware.swift | 2 +- .../Public/Middlewares/VaryMiddleware.swift | 2 +- .../Sources/Public/Protocols/Page.swift | 16 ++ .../Sources/Public/Types/Analytics.swift | 2 + .../Types/Analytics/AnalyticsAttribute.swift | 2 + .../Types/Analytics/AnalyticsEvent.swift | 48 +++++- .../Types/SocialCard/SocialCardImage.swift | 1 + .../Types/SocialCard/SocialCardTag.swift | 4 +- .../Sources/Public/Types/StructuredData.swift | 91 +++--------- .../StructuredDataAddress.swift | 57 ++++++++ .../StructuredData/StructuredDataNode.swift | 4 + .../StructuredDataOrganization.swift | 137 ++++++++++++++++++ .../StructuredDataProperty.swift | 22 +++ .../StructuredData/StructuredDataValue.swift | 2 + .../AnalyticsEventTaggingTests.swift | 2 + .../MarkupContentReportingTests.swift | 68 +++++++++ .../Cases/Public/Protocols/PageTests.swift | 16 +- .../Types/Analytics/AnalyticsEventTests.swift | 17 +++ .../Cases/Public/Types/AnalyticsTests.swift | 2 + .../Cases/Public/Types/SocialCardTests.swift | 3 + .../Public/Types/StructuredDataTests.swift | 129 ++++++++++++----- .../Tests/Utils/Pages/StubPage.swift | 50 +++++-- Packages/Localization/Package.swift | 14 +- Packages/Localization/README.md | 2 +- Packages/Persistence/Package.swift | 14 +- Packages/Persistence/README.md | 2 +- .../Sources/Public/Models/Listing.swift | 2 +- .../Cases/Public/Enumerations/TLSTests.swift | 28 ++-- .../Cases/Public/Methods/ProbeTests.swift | 6 +- .../Cases/Public/Methods/ServiceTests.swift | 40 ++--- Packages/Utility/Package.swift | 14 +- Packages/Utility/README.md | 2 +- Services/Website/Dockerfile | 24 +-- Services/Website/Package.swift | 20 ++- Services/Website/README.md | 6 +- .../Website/Resources/Static/js/shared.js | 29 ++++ .../Internal/Extensions/Int+Euros.swift | 8 +- .../Internal/Extensions/Listing+Display.swift | 4 +- .../Internal/Extensions/Page+Defaults.swift | 13 ++ .../Library/Internal/Pages/ComparePage.swift | 3 +- .../Internal/Pages/SellerDashboardPage.swift | 2 +- .../Middlewares/NoIndexMiddleware.swift | 2 +- .../Extensions/PageDefaultsTests.swift | 11 ++ .../Internal/Pages/CollectorPagesTests.swift | 20 +-- .../Controllers/HealthControllerTests.swift | 72 ++++----- .../Cases/Public/Types/PhotoStoreTests.swift | 8 +- 57 files changed, 864 insertions(+), 273 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Public/Extensions/MarkupContent+Reporting.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataAddress.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataOrganization.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Extensions/MarkupContentReportingTests.swift diff --git a/Packages/Infrastructure/Package.swift b/Packages/Infrastructure/Package.swift index 92d74c7..ef10864 100644 --- a/Packages/Infrastructure/Package.swift +++ b/Packages/Infrastructure/Package.swift @@ -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"), + ] +} diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 616d5bc..6cb4e63 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -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`. diff --git a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift index 4165586..fdcc1ec 100644 --- a/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift +++ b/Packages/Infrastructure/Sources/Internal/Types/FNV1aHash.swift @@ -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 diff --git a/Packages/Infrastructure/Sources/Public/Extensions/AnalyticsEvent+Tagging.swift b/Packages/Infrastructure/Sources/Public/Extensions/AnalyticsEvent+Tagging.swift index 8207a05..3155208 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/AnalyticsEvent+Tagging.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/AnalyticsEvent+Tagging.swift @@ -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() -> [HTMLAttribute] { - attributes.map { + func tagging( + on trigger: Trigger = .click + ) -> [HTMLAttribute] { + attributes(on: trigger).map { .custom( name: $0.name, value: $0.value @@ -22,3 +26,4 @@ public extension Analytics.Event { } } +#endif diff --git a/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift index 71b5fc1..b463ce6 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift @@ -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`). diff --git a/Packages/Infrastructure/Sources/Public/Extensions/MarkupContent+Reporting.swift b/Packages/Infrastructure/Sources/Public/Extensions/MarkupContent+Reporting.swift new file mode 100644 index 0000000..c08b95a --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Extensions/MarkupContent+Reporting.swift @@ -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 { + attributes(contentsOf: event.tagging(on: trigger)) + } + +} +#endif diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift index 38c9d5f..690e2ec 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/CompressionMiddleware.swift @@ -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, diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift index 3d615f2..823c997 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift @@ -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, diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift index 4052d14..3b4feeb 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/NotFoundMiddleware.swift @@ -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) diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift index d1a0f19..bdf0e9b 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/RateLimitMiddleware.swift @@ -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( diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift index 8658e8c..4ce41d1 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/SecurityHeadersMiddleware.swift @@ -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 diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/TrailingSlashRedirectMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/TrailingSlashRedirectMiddleware.swift index 19b61e3..ab7232b 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/TrailingSlashRedirectMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/TrailingSlashRedirectMiddleware.swift @@ -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) diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift index a500a1b..93bf8f7 100644 --- a/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift +++ b/Packages/Infrastructure/Sources/Public/Middlewares/VaryMiddleware.swift @@ -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, diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift index a2f4d6a..69e29ed 100644 --- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift +++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift @@ -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? { diff --git a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift index e100a3c..2858e19 100644 --- a/Packages/Infrastructure/Sources/Public/Types/Analytics.swift +++ b/Packages/Infrastructure/Sources/Public/Types/Analytics.swift @@ -1,3 +1,4 @@ +#if Analytics import Foundation /// The Umami analytics tracker a page embeds as a deferred `"# )) } + #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`() { diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/Analytics/AnalyticsEventTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/Analytics/AnalyticsEventTests.swift index 6256ac6..16ffb73 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/Analytics/AnalyticsEventTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/Analytics/AnalyticsEventTests.swift @@ -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 diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift index 39e297c..3c6a3f7 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/AnalyticsTests.swift @@ -1,3 +1,4 @@ +#if Analytics import Testing @testable import Infrastructure @@ -133,3 +134,4 @@ struct AnalyticsTests { } } +#endif diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift index a1c2a1e..628ca73 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift @@ -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) } diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift index f7eac81..76ad826 100644 --- a/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift +++ b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift @@ -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/" + organization: .init( + name: #"A "Quoted" \ Site"#, + url: "https://site.example/" + ) ) #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 " let data = StructuredData( - name: name, - url: "https://site.example/", - logo: "https://site.example/logo.png", - profiles: ["https://social.example/a-site"] + organization: .init( + name: name, + url: "https://site.example/", + 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 diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift index d719f1d..0891304 100644 --- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift +++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift @@ -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 diff --git a/Packages/Localization/Package.swift b/Packages/Localization/Package.swift index 8778c92..5705d0a 100644 --- a/Packages/Localization/Package.swift +++ b/Packages/Localization/Package.swift @@ -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"), + ] +} diff --git a/Packages/Localization/README.md b/Packages/Localization/README.md index 7be3a52..871f802 100644 --- a/Packages/Localization/README.md +++ b/Packages/Localization/README.md @@ -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. diff --git a/Packages/Persistence/Package.swift b/Packages/Persistence/Package.swift index 85dba7c..dc3deea 100644 --- a/Packages/Persistence/Package.swift +++ b/Packages/Persistence/Package.swift @@ -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"), + ] +} diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index 99540dd..071de04 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -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. diff --git a/Packages/Persistence/Sources/Public/Models/Listing.swift b/Packages/Persistence/Sources/Public/Models/Listing.swift index 9fe06d6..0415c5e 100644 --- a/Packages/Persistence/Sources/Public/Models/Listing.swift +++ b/Packages/Persistence/Sources/Public/Models/Listing.swift @@ -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 diff --git a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift index 37a20c6..a9e0649 100644 --- a/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Enumerations/TLSTests.swift @@ -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 diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift index c01ab7e..ba54608 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ProbeTests.swift @@ -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 diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift index eb8c794..6f747da 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift @@ -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() + } } } diff --git a/Packages/Utility/Package.swift b/Packages/Utility/Package.swift index 574189c..2bd1408 100644 --- a/Packages/Utility/Package.swift +++ b/Packages/Utility/Package.swift @@ -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"), + ] +} diff --git a/Packages/Utility/README.md b/Packages/Utility/README.md index 9e8b0f5..6fcc9fd 100644 --- a/Packages/Utility/README.md +++ b/Packages/Utility/README.md @@ -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. diff --git a/Services/Website/Dockerfile b/Services/Website/Dockerfile index a765ec5..f14efcc 100644 --- a/Services/Website/Dockerfile +++ b/Services/Website/Dockerfile @@ -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 diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index 10890e8..de2412c 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -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"), + ] +} diff --git a/Services/Website/README.md b/Services/Website/README.md index d1843d4..5859e07 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -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` | `` | The page's one-line description. | | `canonicalURL` | `` | 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 `` 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` | `