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` | `