Project updates from Template
This commit contains the latest updates from the generic Website template, which move the toolchain to Swift 6.4, gate analytics and structured data behind traits, and rework page routing: - Updated the Swift toolchain for all the package dependencies and the Website service to version 6.4. (345b305) - Adapted all the existing middlewares in the Infrastructure package to Swift 6.4. (26008df) - Moved test resource teardown in the Persistence and the Website library into defer blocks. (27984a9) - Gated analytics behind a trait and reworked structured data in the Infrastructure package. (c7d4769) - Enabled the Analytics trait in the Website service. (f6a6bbd) - Reworked page routing in the Website service. (d1cb2c1) - Gated the structured data features in the Infrastructure package behind a trait. (f0a8791) - Enabled the StructuredData trait of the Infrastructure package in the Website service. (bbfdeb3) - Fixed the asset pin and a build warning in the Dockerfile of the Website service. (6ed0f9b) The root README change in 345b305 was left out, as this project no longer carries that file. From d1cb2c1, the RootController rework was left out, as the prototype serves a session-aware landing page and routes its other pages through their own controllers; the view-event reporter was appended to the prototype's shared script instead. The prototype code was adapted to the stricter Swift 6.4 settings: `String(format:)` was swapped for safe formatting, the NoIndex middleware takes a `@concurrent` next handler, and the tests flagged by warnings-as-errors were tightened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
326299fc35
commit
911424de24
57 files changed
+864
-273
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version: 6.3
|
// swift-tools-version: 6.4
|
||||||
|
|
||||||
import PackageDescription
|
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: [
|
dependencies: [
|
||||||
.package(
|
.package(
|
||||||
path: "../Localization"
|
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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
|
|||||||
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `CompressionMiddleware`, `RateLimitMiddleware`, `NotFoundMiddleware` |
|
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `CompressionMiddleware`, `RateLimitMiddleware`, `NotFoundMiddleware` |
|
||||||
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
| 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 |
|
| 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 |
|
| 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 | `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 |
|
| 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) |
|
| 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` |
|
| 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 |
|
| 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.
|
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`.
|
- **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.
|
- **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.
|
- **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`.
|
- **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.
|
- **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
|
├── Public/ public API
|
||||||
│ ├── Builders/ RouteCollectionBuilder
|
│ ├── Builders/ RouteCollectionBuilder
|
||||||
│ ├── Enumerations/ AssetExtension
|
│ ├── 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
|
│ ├── Methods/ FingerprintAssets
|
||||||
│ ├── Middlewares/ the seven HTTP middlewares
|
│ ├── Middlewares/ the seven HTTP middlewares
|
||||||
│ ├── Protocols/ Asset, Page, RouterController
|
│ ├── Protocols/ Asset, Page, RouterController
|
||||||
@@ -49,7 +49,9 @@ Tests/
|
|||||||
## Testing
|
## 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.
|
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
|
## 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.
|
- 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`.
|
- Package dependencies: the local `Localization` package, `elementary`, `hummingbird`, and — for `CompressionMiddleware` and the responses' gzip — `hummingbird-compression` and `compress-nio`.
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Hashes bytes with the FNV-1a 64-bit algorithm.
|
/// 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
|
/// 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.
|
/// Reading it does not consume the running hash: more bytes can be combined afterwards.
|
||||||
var digest: String {
|
var digest: String {
|
||||||
String(
|
let hex = String(hash, radix: 16)
|
||||||
format: "%016llx",
|
|
||||||
hash
|
return String(repeating: "0", count: 16 - hex.count) + hex
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Functions
|
// MARK: Functions
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if Analytics
|
||||||
import Elementary
|
import Elementary
|
||||||
|
|
||||||
public extension Analytics.Event {
|
public extension Analytics.Event {
|
||||||
@@ -11,9 +12,12 @@ public extension Analytics.Event {
|
|||||||
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
|
/// .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.
|
/// - Returns: one HTML attribute per event attribute, applied verbatim.
|
||||||
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
|
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>(
|
||||||
attributes.map {
|
on trigger: Trigger = .click
|
||||||
|
) -> [HTMLAttribute<Tag>] {
|
||||||
|
attributes(on: trigger).map {
|
||||||
.custom(
|
.custom(
|
||||||
name: $0.name,
|
name: $0.name,
|
||||||
value: $0.value
|
value: $0.value
|
||||||
@@ -22,3 +26,4 @@ public extension Analytics.Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -5,6 +5,8 @@ public extension HTTPField.Name {
|
|||||||
static let permissionsPolicy = Self("Permissions-Policy")!
|
static let permissionsPolicy = Self("Permissions-Policy")!
|
||||||
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
|
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
|
||||||
static let referrerPolicy = Self("Referrer-Policy")!
|
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`).
|
/// The `X-Robots-Tag` field name (not provided as a standard `HTTPField.Name`).
|
||||||
static let robotsTag = Self("X-Robots-Tag")!
|
static let robotsTag = Self("X-Robots-Tag")!
|
||||||
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#if Analytics
|
||||||
|
import Elementary
|
||||||
|
|
||||||
|
public extension MarkupContent where Self: _Attributed, Tag: MarkupTrait.AllowsAttributes {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Tags the element with the event it reports to the analytics tracker.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// a(.href(url)) { "Listen" }
|
||||||
|
/// .reporting(.init(name: "playlist"))
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - event: the event the element reports.
|
||||||
|
/// - trigger: what reports the event; a click by default.
|
||||||
|
/// - Returns: the element, carrying the event's ``Analytics/Event/attributes(on:)``.
|
||||||
|
func reporting(
|
||||||
|
_ event: Analytics.Event,
|
||||||
|
on trigger: Analytics.Event.Trigger = .click
|
||||||
|
) -> Self {
|
||||||
|
attributes(contentsOf: event.tagging(on: trigger))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension MarkupContent where Tag: MarkupTrait.AllowsAttributes {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Tags the content with the event it reports to the analytics tracker.
|
||||||
|
///
|
||||||
|
/// The fallback for content that is not an element itself, mirroring Elementary's `attributes(contentsOf:)`.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - event: the event the content reports.
|
||||||
|
/// - trigger: what reports the event; a click by default.
|
||||||
|
/// - Returns: the content, carrying the event's ``Analytics/Event/attributes(on:)``.
|
||||||
|
@_disfavoredOverload
|
||||||
|
func reporting(
|
||||||
|
_ event: Analytics.Event,
|
||||||
|
on trigger: Analytics.Event.Trigger = .click
|
||||||
|
) -> _AttributedContent<Self> {
|
||||||
|
attributes(contentsOf: event.tagging(on: trigger))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -47,7 +47,7 @@ extension CompressionMiddleware: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let response = try await next(
|
let response = try await next(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ extension HTTPSRedirectMiddleware: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
guard
|
guard
|
||||||
isEnabled,
|
isEnabled,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ extension NotFoundMiddleware: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
do {
|
do {
|
||||||
return try await next(request, context)
|
return try await next(request, context)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ extension RateLimitMiddleware: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let admission = buckets.admit(
|
let admission = buckets.admit(
|
||||||
client(
|
client(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
var response: Response
|
var response: Response
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ extension TrailingSlashRedirectMiddleware: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let path = request.uri.path
|
let path = request.uri.path
|
||||||
let canonical = canonicalPath(of: path)
|
let canonical = canonicalPath(of: path)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ extension VaryMiddleware: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
var response = try await next(
|
var response = try await next(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import Foundation
|
|||||||
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
|
/// 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
|
/// 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.
|
/// 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 {
|
public protocol Page: HTMLDocument, Sendable {
|
||||||
|
|
||||||
// MARK: Associated types
|
// MARK: Associated types
|
||||||
@@ -18,8 +20,10 @@ public protocol Page: HTMLDocument, Sendable {
|
|||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
|
#if Analytics
|
||||||
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
|
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
|
||||||
var analytics: Analytics? { get }
|
var analytics: Analytics? { get }
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
var assetVersion: String? { get }
|
var assetVersion: String? { get }
|
||||||
@@ -48,8 +52,10 @@ public protocol Page: HTMLDocument, Sendable {
|
|||||||
/// to omit them.
|
/// to omit them.
|
||||||
var socialCard: SocialCard? { get }
|
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.
|
/// 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 }
|
var structuredData: StructuredData? { get }
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The stylesheets linked in the document head, in order.
|
/// The stylesheets linked in the document head, in order.
|
||||||
var stylesheets: [any Asset] { get }
|
var stylesheets: [any Asset] { get }
|
||||||
@@ -94,6 +100,7 @@ public extension Page {
|
|||||||
.content("width=device-width, initial-scale=1")
|
.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.
|
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
|
||||||
if let origin = analytics?.origin {
|
if let origin = analytics?.origin {
|
||||||
link(
|
link(
|
||||||
@@ -101,6 +108,7 @@ public extension Page {
|
|||||||
.href(origin)
|
.href(origin)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if let summary {
|
if let summary {
|
||||||
meta(
|
meta(
|
||||||
@@ -128,6 +136,7 @@ public extension Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if StructuredData
|
||||||
if let structuredData {
|
if let structuredData {
|
||||||
script(.custom(
|
script(.custom(
|
||||||
name: "type",
|
name: "type",
|
||||||
@@ -136,7 +145,9 @@ public extension Page {
|
|||||||
HTMLRaw(structuredData.payload)
|
HTMLRaw(structuredData.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if Analytics
|
||||||
if let analytics {
|
if let analytics {
|
||||||
script(
|
script(
|
||||||
.defer,
|
.defer,
|
||||||
@@ -160,6 +171,7 @@ public extension Page {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
metadata
|
metadata
|
||||||
|
|
||||||
@@ -184,20 +196,24 @@ public extension Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if Analytics
|
||||||
/// The analytics tracker is omitted unless the page provides one.
|
/// The analytics tracker is omitted unless the page provides one.
|
||||||
var analytics: Analytics? {
|
var analytics: Analytics? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The social card is omitted unless the page provides one.
|
/// The social card is omitted unless the page provides one.
|
||||||
var socialCard: SocialCard? {
|
var socialCard: SocialCard? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if StructuredData
|
||||||
/// The structured data is omitted unless the page provides one.
|
/// The structured data is omitted unless the page provides one.
|
||||||
var structuredData: StructuredData? {
|
var structuredData: StructuredData? {
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The summary is omitted unless the page provides one.
|
/// The summary is omitted unless the page provides one.
|
||||||
var summary: String? {
|
var summary: String? {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if Analytics
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
|
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
|
||||||
@@ -114,3 +115,4 @@ public struct Analytics: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if Analytics
|
||||||
extension Analytics {
|
extension Analytics {
|
||||||
/// A name-value pair rendered as an HTML attribute.
|
/// A name-value pair rendered as an HTML attribute.
|
||||||
///
|
///
|
||||||
@@ -29,3 +30,4 @@ extension Analytics {
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
#if Analytics
|
||||||
extension Analytics {
|
extension Analytics {
|
||||||
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
|
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
|
||||||
///
|
///
|
||||||
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
|
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
|
||||||
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
|
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
|
||||||
|
///
|
||||||
|
/// A ``Trigger/view`` renders under `data-view-event`, which a page script reports instead of the tracker.
|
||||||
public struct Event: Equatable, Sendable {
|
public struct Event: Equatable, Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@@ -34,22 +37,51 @@ extension Analytics {
|
|||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
|
/// The event's attributes when clicked; see ``attributes(on:)``.
|
||||||
public var attributes: [Attribute] {
|
public var attributes: [Attribute] {
|
||||||
[.init(Constant.Name.prefix, value: name)]
|
attributes(on: .click)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Functions
|
||||||
|
|
||||||
|
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
|
||||||
|
/// - Parameter trigger: what reports the event.
|
||||||
|
/// - Returns: one attribute for the name, then one per property.
|
||||||
|
public func attributes(on trigger: Trigger) -> [Attribute] {
|
||||||
|
[.init(trigger.prefix, value: name)]
|
||||||
+ properties.map {
|
+ properties.map {
|
||||||
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
|
.init("\(trigger.prefix)-\($0.name)", value: $0.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Types
|
||||||
|
|
||||||
private enum Constant {
|
extension Analytics.Event {
|
||||||
enum Name {
|
/// What reports an event.
|
||||||
/// The attribute name the tracker watches for, and the prefix each property renders under.
|
public enum Trigger: Sendable {
|
||||||
static let prefix = "data-umami-event"
|
/// A click, which the tracker records itself.
|
||||||
|
case click
|
||||||
|
/// The element first coming fully on screen, which a page script records.
|
||||||
|
case view
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension Analytics.Event.Trigger {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The attribute name marking the event, and the prefix each property renders under.
|
||||||
|
var prefix: String {
|
||||||
|
switch self {
|
||||||
|
case .click: "data-umami-event"
|
||||||
|
case .view: "data-view-event"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -45,6 +45,7 @@ extension SocialCard {
|
|||||||
Tag(String(width), name: .imageWidth),
|
Tag(String(width), name: .imageWidth),
|
||||||
Tag(String(height), name: .imageHeight),
|
Tag(String(height), name: .imageHeight),
|
||||||
alt.map { Tag($0, name: .imageAlt) },
|
alt.map { Tag($0, name: .imageAlt) },
|
||||||
|
alt.map { Tag($0, name: .twitterImageAlt) },
|
||||||
]
|
]
|
||||||
|
|
||||||
return tags.compactMap { $0 }
|
return tags.compactMap { $0 }
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ extension SocialCard.Tag {
|
|||||||
case title = "og:title"
|
case title = "og:title"
|
||||||
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
|
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
|
||||||
case twitter = "twitter:card"
|
case twitter = "twitter:card"
|
||||||
|
/// The `twitter:image:alt` tag, carrying the share image's text for assistive technologies.
|
||||||
|
case twitterImageAlt = "twitter:image:alt"
|
||||||
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
|
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
|
||||||
case type = "og:type"
|
case type = "og:type"
|
||||||
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
|
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
|
||||||
@@ -84,7 +86,7 @@ public extension SocialCard.Tag.Name {
|
|||||||
|
|
||||||
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
|
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
|
||||||
var attribute: SocialCard.Tag.Attribute {
|
var attribute: SocialCard.Tag.Attribute {
|
||||||
self == .twitter ? .name : .property
|
rawValue.hasPrefix("twitter:") ? .name : .property
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
#if StructuredData
|
||||||
/// The structured data of a page, rendered as a JSON-LD script in the document head.
|
/// The structured data of a page, rendered as a JSON-LD script in the document head.
|
||||||
///
|
///
|
||||||
/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
|
/// The data is a graph of schema.org ``Node`` values — each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
|
||||||
/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
|
/// order — and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
|
||||||
/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
|
/// shape directly, or uses ``init(organization:inLanguage:)`` for the site-wide pair every page shares.
|
||||||
///
|
///
|
||||||
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
|
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
|
||||||
/// page providing the data.
|
/// page providing the data.
|
||||||
@@ -37,6 +38,15 @@ public struct StructuredData: Equatable, Sendable {
|
|||||||
url + "#organization"
|
url + "#organization"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `@id` of the `WebSite` node the site-wide initializer builds, for a page node's `isPartOf` to reference.
|
||||||
|
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
|
||||||
|
/// - Returns: the website node's `@id`.
|
||||||
|
public static func websiteID(
|
||||||
|
forSiteURL url: String
|
||||||
|
) -> String {
|
||||||
|
url + "#website"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
|
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
|
||||||
@@ -53,75 +63,18 @@ public struct StructuredData: Equatable, Sendable {
|
|||||||
|
|
||||||
public extension StructuredData {
|
public extension StructuredData {
|
||||||
|
|
||||||
/// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
|
/// Creates the site-wide structured data: the organization's node and the `WebSite` node it publishes, both with an `@id`.
|
||||||
/// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
|
|
||||||
/// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
|
|
||||||
/// A property whose fact the data does not carry is left out.
|
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - name: the name of the organization and the site.
|
/// - organization: the organization publishing the site.
|
||||||
/// - url: the absolute URL the site is served at.
|
|
||||||
/// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property.
|
|
||||||
/// - description: what the organization does, or `nil` (the default) to omit its property.
|
|
||||||
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
|
|
||||||
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
|
|
||||||
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
|
|
||||||
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
|
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
|
||||||
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
|
|
||||||
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
|
|
||||||
init(
|
init(
|
||||||
name: String,
|
organization: Organization,
|
||||||
url: String,
|
inLanguage: [String] = []
|
||||||
alternateName: String? = nil,
|
|
||||||
description: String? = nil,
|
|
||||||
areaServed: String? = nil,
|
|
||||||
email: String? = nil,
|
|
||||||
logo: String? = nil,
|
|
||||||
inLanguage: [String] = [],
|
|
||||||
profiles: [String] = [],
|
|
||||||
founder: String? = nil
|
|
||||||
) {
|
) {
|
||||||
let id = Self.organizationID(forSiteURL: url)
|
|
||||||
|
|
||||||
var organization: [Property] = [
|
|
||||||
.init(.name, value: .string(name)),
|
|
||||||
.init(.url, value: .string(url)),
|
|
||||||
]
|
|
||||||
|
|
||||||
if let alternateName {
|
|
||||||
organization.append(.init(.alternateName, value: .string(alternateName)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let description {
|
|
||||||
organization.append(.init(.description, value: .string(description)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let areaServed {
|
|
||||||
organization.append(.init(.areaServed, value: .string(areaServed)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let email {
|
|
||||||
organization.append(.init(.email, value: .string(email)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let logo {
|
|
||||||
organization.append(.init(.logo, value:.string(logo)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !profiles.isEmpty {
|
|
||||||
organization.append(.init(
|
|
||||||
.sameAs,
|
|
||||||
value: .array(profiles.map(Value.string))
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let founder {
|
|
||||||
organization.append(.init(.founder, value: .reference(founder)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var website: [Property] = [
|
var website: [Property] = [
|
||||||
.init(.name, value: .string(name)),
|
.init(.name, value: .string(organization.name)),
|
||||||
.init(.url, value: .string(url)),
|
.init(.url, value: .string(organization.url)),
|
||||||
.init(.publisher, value: .reference(id)),
|
.init(.publisher, value: .reference(Self.organizationID(forSiteURL: organization.url))),
|
||||||
]
|
]
|
||||||
|
|
||||||
if !inLanguage.isEmpty {
|
if !inLanguage.isEmpty {
|
||||||
@@ -132,13 +85,10 @@ public extension StructuredData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.init(nodes: [
|
self.init(nodes: [
|
||||||
.init(
|
organization.node,
|
||||||
type: .organization,
|
|
||||||
id: id,
|
|
||||||
properties: organization
|
|
||||||
),
|
|
||||||
.init(
|
.init(
|
||||||
type: .website,
|
type: .website,
|
||||||
|
id: Self.websiteID(forSiteURL: organization.url),
|
||||||
properties: website
|
properties: website
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
@@ -159,3 +109,4 @@ private extension StructuredData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#if StructuredData
|
||||||
|
extension StructuredData {
|
||||||
|
/// A schema.org `PostalAddress`.
|
||||||
|
public struct Address: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The ISO 3166-1 alpha-2 country code (e.g. `NL`).
|
||||||
|
public let country: String
|
||||||
|
|
||||||
|
/// The city.
|
||||||
|
public let locality: String
|
||||||
|
|
||||||
|
/// The postal code.
|
||||||
|
public let postalCode: String
|
||||||
|
|
||||||
|
/// The street and house number.
|
||||||
|
public let street: String
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a postal address.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - street: the street and house number.
|
||||||
|
/// - postalCode: the postal code.
|
||||||
|
/// - locality: the city.
|
||||||
|
/// - country: the ISO 3166-1 alpha-2 country code (e.g. `NL`).
|
||||||
|
public init(
|
||||||
|
street: String,
|
||||||
|
postalCode: String,
|
||||||
|
locality: String,
|
||||||
|
country: String
|
||||||
|
) {
|
||||||
|
self.country = country
|
||||||
|
self.locality = locality
|
||||||
|
self.postalCode = postalCode
|
||||||
|
self.street = street
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The address's node.
|
||||||
|
var node: Node {
|
||||||
|
.init(
|
||||||
|
type: .postalAddress,
|
||||||
|
properties: [
|
||||||
|
.init(.streetAddress, value: .string(street)),
|
||||||
|
.init(.postalCode, value: .string(postalCode)),
|
||||||
|
.init(.addressLocality, value: .string(locality)),
|
||||||
|
.init(.addressCountry, value: .string(country)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if StructuredData
|
||||||
extension StructuredData {
|
extension StructuredData {
|
||||||
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
|
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
|
||||||
public struct Node: Equatable, Sendable {
|
public struct Node: Equatable, Sendable {
|
||||||
@@ -87,6 +88,9 @@ extension StructuredData.Node {
|
|||||||
public extension StructuredData.Node.Kind {
|
public extension StructuredData.Node.Kind {
|
||||||
/// An organization, e.g. the one publishing a website.
|
/// An organization, e.g. the one publishing a website.
|
||||||
static let organization: Self = "Organization"
|
static let organization: Self = "Organization"
|
||||||
|
/// A postal address.
|
||||||
|
static let postalAddress: Self = "PostalAddress"
|
||||||
/// A website.
|
/// A website.
|
||||||
static let website: Self = "WebSite"
|
static let website: Self = "WebSite"
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#if StructuredData
|
||||||
|
extension StructuredData {
|
||||||
|
/// A schema.org `Organization` publishing a site; a fact it does not carry is left out.
|
||||||
|
public struct Organization: Equatable, Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The postal address.
|
||||||
|
public let address: Address?
|
||||||
|
|
||||||
|
/// The name it is also known by.
|
||||||
|
public let alternateName: String?
|
||||||
|
|
||||||
|
/// The area it serves.
|
||||||
|
public let areaServed: String?
|
||||||
|
|
||||||
|
/// What it does.
|
||||||
|
public let description: String?
|
||||||
|
|
||||||
|
/// The email address.
|
||||||
|
public let email: String?
|
||||||
|
|
||||||
|
/// The `@id` of the `Person` node founding it.
|
||||||
|
public let founder: String?
|
||||||
|
|
||||||
|
/// The ISO 8601 date it was founded.
|
||||||
|
public let foundingDate: String?
|
||||||
|
|
||||||
|
/// The ISO 6523 identifier.
|
||||||
|
public let iso6523Code: String?
|
||||||
|
|
||||||
|
/// The registered name.
|
||||||
|
public let legalName: String?
|
||||||
|
|
||||||
|
/// The absolute URL of the logo.
|
||||||
|
public let logo: String?
|
||||||
|
|
||||||
|
/// The name, shared by the site.
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
/// The absolute URLs of its public profiles.
|
||||||
|
public let profiles: [String]
|
||||||
|
|
||||||
|
/// The phone number.
|
||||||
|
public let telephone: String?
|
||||||
|
|
||||||
|
/// The absolute URL of the site.
|
||||||
|
public let url: String
|
||||||
|
|
||||||
|
/// The VAT number.
|
||||||
|
public let vatID: String?
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates an organization.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - name: the name, shared by the site.
|
||||||
|
/// - url: the absolute URL of the site.
|
||||||
|
/// - alternateName: the name it is also known by.
|
||||||
|
/// - legalName: the registered name.
|
||||||
|
/// - description: what it does.
|
||||||
|
/// - areaServed: the area it serves.
|
||||||
|
/// - email: the email address.
|
||||||
|
/// - telephone: the phone number.
|
||||||
|
/// - address: the postal address.
|
||||||
|
/// - vatID: the VAT number.
|
||||||
|
/// - iso6523Code: the ISO 6523 identifier.
|
||||||
|
/// - logo: the absolute URL of the logo.
|
||||||
|
/// - profiles: the absolute URLs of its public profiles.
|
||||||
|
/// - foundingDate: the ISO 8601 date it was founded.
|
||||||
|
/// - founder: the `@id` of the `Person` node founding it.
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
alternateName: String? = nil,
|
||||||
|
legalName: String? = nil,
|
||||||
|
description: String? = nil,
|
||||||
|
areaServed: String? = nil,
|
||||||
|
email: String? = nil,
|
||||||
|
telephone: String? = nil,
|
||||||
|
address: Address? = nil,
|
||||||
|
vatID: String? = nil,
|
||||||
|
iso6523Code: String? = nil,
|
||||||
|
logo: String? = nil,
|
||||||
|
profiles: [String] = [],
|
||||||
|
foundingDate: String? = nil,
|
||||||
|
founder: String? = nil
|
||||||
|
) {
|
||||||
|
self.address = address
|
||||||
|
self.alternateName = alternateName
|
||||||
|
self.areaServed = areaServed
|
||||||
|
self.description = description
|
||||||
|
self.email = email
|
||||||
|
self.founder = founder
|
||||||
|
self.foundingDate = foundingDate
|
||||||
|
self.iso6523Code = iso6523Code
|
||||||
|
self.legalName = legalName
|
||||||
|
self.logo = logo
|
||||||
|
self.name = name
|
||||||
|
self.profiles = profiles
|
||||||
|
self.telephone = telephone
|
||||||
|
self.url = url
|
||||||
|
self.vatID = vatID
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The organization's node.
|
||||||
|
var node: Node {
|
||||||
|
let properties: [Property?] = [
|
||||||
|
.init(.name, value: .string(name)),
|
||||||
|
.init(.url, value: .string(url)),
|
||||||
|
alternateName.map { .init(.alternateName, value: .string($0)) },
|
||||||
|
legalName.map { .init(.legalName, value: .string($0)) },
|
||||||
|
description.map { .init(.description, value: .string($0)) },
|
||||||
|
areaServed.map { .init(.areaServed, value: .string($0)) },
|
||||||
|
email.map { .init(.email, value: .string($0)) },
|
||||||
|
telephone.map { .init(.telephone, value: .string($0)) },
|
||||||
|
address.map { .init(.address, value: .node($0.node)) },
|
||||||
|
vatID.map { .init(.vatID, value: .string($0)) },
|
||||||
|
iso6523Code.map { .init(.iso6523Code, value: .string($0)) },
|
||||||
|
logo.map { .init(.logo, value: .string($0)) },
|
||||||
|
profiles.isEmpty ? nil : .init(.sameAs, value: .array(profiles.map(Value.string))),
|
||||||
|
foundingDate.map { .init(.foundingDate, value: .string($0)) },
|
||||||
|
founder.map { .init(.founder, value: .reference($0)) },
|
||||||
|
]
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
type: .organization,
|
||||||
|
id: StructuredData.organizationID(forSiteURL: url),
|
||||||
|
properties: properties.compactMap { $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
+22
@@ -1,3 +1,4 @@
|
|||||||
|
#if StructuredData
|
||||||
extension StructuredData {
|
extension StructuredData {
|
||||||
/// A named property of a ``Node``, in the position it renders.
|
/// A named property of a ``Node``, in the position it renders.
|
||||||
public struct Property: Equatable, Sendable {
|
public struct Property: Equatable, Sendable {
|
||||||
@@ -69,6 +70,12 @@ extension StructuredData.Property {
|
|||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
public extension StructuredData.Property.Name {
|
public extension StructuredData.Property.Name {
|
||||||
|
/// The postal address of the thing a node describes.
|
||||||
|
static let address: Self = "address"
|
||||||
|
/// The country of a postal address.
|
||||||
|
static let addressCountry: Self = "addressCountry"
|
||||||
|
/// The city of a postal address.
|
||||||
|
static let addressLocality: Self = "addressLocality"
|
||||||
/// The name the thing a node describes is also known by.
|
/// The name the thing a node describes is also known by.
|
||||||
static let alternateName: Self = "alternateName"
|
static let alternateName: Self = "alternateName"
|
||||||
/// The area an organization serves.
|
/// The area an organization serves.
|
||||||
@@ -79,16 +86,31 @@ public extension StructuredData.Property.Name {
|
|||||||
static let email: Self = "email"
|
static let email: Self = "email"
|
||||||
/// The person who founded an organization.
|
/// The person who founded an organization.
|
||||||
static let founder: Self = "founder"
|
static let founder: Self = "founder"
|
||||||
|
/// The ISO 8601 date an organization was founded.
|
||||||
|
static let foundingDate: Self = "foundingDate"
|
||||||
/// The language a creative work — a site, a page — is published in.
|
/// The language a creative work — a site, a page — is published in.
|
||||||
static let inLanguage: Self = "inLanguage"
|
static let inLanguage: Self = "inLanguage"
|
||||||
|
/// An organization's ISO 6523 identifier.
|
||||||
|
static let iso6523Code: Self = "iso6523Code"
|
||||||
|
/// An organization's registered name.
|
||||||
|
static let legalName: Self = "legalName"
|
||||||
/// The absolute URL of an organization's logo.
|
/// The absolute URL of an organization's logo.
|
||||||
static let logo: Self = "logo"
|
static let logo: Self = "logo"
|
||||||
/// The name of the thing a node describes.
|
/// The name of the thing a node describes.
|
||||||
static let name: Self = "name"
|
static let name: Self = "name"
|
||||||
|
/// The postal code of a postal address.
|
||||||
|
static let postalCode: Self = "postalCode"
|
||||||
/// The organization publishing a website.
|
/// The organization publishing a website.
|
||||||
static let publisher: Self = "publisher"
|
static let publisher: Self = "publisher"
|
||||||
/// The absolute URLs of the profiles that also identify the thing a node describes.
|
/// The absolute URLs of the profiles that also identify the thing a node describes.
|
||||||
static let sameAs: Self = "sameAs"
|
static let sameAs: Self = "sameAs"
|
||||||
|
/// The street and house number of a postal address.
|
||||||
|
static let streetAddress: Self = "streetAddress"
|
||||||
|
/// The phone number of the thing a node describes.
|
||||||
|
static let telephone: Self = "telephone"
|
||||||
/// The absolute URL of the thing a node describes.
|
/// The absolute URL of the thing a node describes.
|
||||||
static let url: Self = "url"
|
static let url: Self = "url"
|
||||||
|
/// An organization's VAT number.
|
||||||
|
static let vatID: Self = "vatID"
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if StructuredData
|
||||||
extension StructuredData {
|
extension StructuredData {
|
||||||
/// A value of a ``Property``: a string, a number, a list, a nested node, or a reference to another node.
|
/// A value of a ``Property``: a string, a number, a list, a nested node, or a reference to another node.
|
||||||
///
|
///
|
||||||
@@ -69,3 +70,4 @@ extension StructuredData.Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if Analytics
|
||||||
import Elementary
|
import Elementary
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -57,3 +58,4 @@ struct AnalyticsEventTaggingTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#if Analytics
|
||||||
|
import Elementary
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Infrastructure
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"MarkupContent+Reporting extension",
|
||||||
|
.tags(.extension)
|
||||||
|
)
|
||||||
|
struct MarkupContentReportingTests {
|
||||||
|
|
||||||
|
// MARK: Methods tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `renders the same markup as applying the event's tagging`() {
|
||||||
|
let event = Analytics.Event(
|
||||||
|
name: "playlist",
|
||||||
|
properties: ["set": "avc-xi"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let reported = a(.href("/")) { "Listen" }
|
||||||
|
.reporting(event)
|
||||||
|
.render()
|
||||||
|
let tagged = a(.href("/")) { "Listen" }
|
||||||
|
.attributes(contentsOf: event.tagging())
|
||||||
|
.render()
|
||||||
|
|
||||||
|
#expect(reported == tagged)
|
||||||
|
#expect(reported == #"<a href="/" data-umami-event="playlist" data-umami-event-set="avc-xi">Listen</a>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `keeps the attributes chained before it`() {
|
||||||
|
let rendered = a(.href("https://example.com")) { "Listen" }
|
||||||
|
.attributes(.target(.blank), when: true)
|
||||||
|
.reporting(.init(name: "playlist"))
|
||||||
|
.render()
|
||||||
|
|
||||||
|
#expect(rendered == #"<a href="https://example.com" target="_blank" data-umami-event="playlist">Listen</a>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `tags a view under its own prefix`() {
|
||||||
|
let rendered = h2 { "KLM" }
|
||||||
|
.reporting(
|
||||||
|
.init(
|
||||||
|
name: "case_view",
|
||||||
|
properties: ["case": "klm"]
|
||||||
|
),
|
||||||
|
on: .view
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
|
||||||
|
#expect(rendered == #"<h2 data-view-event="case_view" data-view-event-case="klm">KLM</h2>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `reports from an SVG tag`() {
|
||||||
|
let rendered = SVG.path {}
|
||||||
|
.reporting(.init(name: "logo"))
|
||||||
|
.render()
|
||||||
|
|
||||||
|
#expect(rendered.contains(#"data-umami-event="logo""#))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -101,6 +101,7 @@ struct PageTests {
|
|||||||
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
|
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if StructuredData
|
||||||
@Test
|
@Test
|
||||||
func `omits the structured data script by default`() {
|
func `omits the structured data script by default`() {
|
||||||
let html = StubPage().render()
|
let html = StubPage().render()
|
||||||
@@ -111,21 +112,25 @@ struct PageTests {
|
|||||||
@Test
|
@Test
|
||||||
func `renders the structured data script when provided`() {
|
func `renders the structured data script when provided`() {
|
||||||
let html = StubPage(structuredData: .init(
|
let html = StubPage(structuredData: .init(
|
||||||
name: "Stub Site",
|
organization: .init(
|
||||||
url: "https://stub.example/",
|
name: "Stub Site",
|
||||||
logo: "https://stub.example/logo.png",
|
url: "https://stub.example/",
|
||||||
profiles: ["https://social.example/stub"]
|
logo: "https://stub.example/logo.png",
|
||||||
|
profiles: ["https://social.example/stub"]
|
||||||
|
)
|
||||||
)).render()
|
)).render()
|
||||||
|
|
||||||
#expect(html.contains(
|
#expect(html.contains(
|
||||||
#"<script type="application/ld+json">"# +
|
#"<script type="application/ld+json">"# +
|
||||||
#"{"@context":"https://schema.org","@graph":["# +
|
#"{"@context":"https://schema.org","@graph":["# +
|
||||||
#"{"@type":"Organization","@id":"https://stub.example/#organization","name":"Stub Site","url":"https://stub.example/","logo":"https://stub.example/logo.png","sameAs":["https://social.example/stub"]},"# +
|
#"{"@type":"Organization","@id":"https://stub.example/#organization","name":"Stub Site","url":"https://stub.example/","logo":"https://stub.example/logo.png","sameAs":["https://social.example/stub"]},"# +
|
||||||
#"{"@type":"WebSite","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
|
#"{"@type":"WebSite","@id":"https://stub.example/#website","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
|
||||||
#"</script>"#
|
#"</script>"#
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if Analytics
|
||||||
@Test
|
@Test
|
||||||
func `omits the analytics tracker by default`() {
|
func `omits the analytics tracker by default`() {
|
||||||
let html = StubPage().render()
|
let html = StubPage().render()
|
||||||
@@ -194,6 +199,7 @@ struct PageTests {
|
|||||||
#expect(!html.contains("data-do-not-track"))
|
#expect(!html.contains("data-do-not-track"))
|
||||||
#expect(!html.contains("data-performance"))
|
#expect(!html.contains("data-performance"))
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `appends the version token to the asset URLs`() {
|
func `appends the version token to the asset URLs`() {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if Analytics
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import Infrastructure
|
@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
|
@Test
|
||||||
func `renders the name first, then the properties in the order given`() {
|
func `renders the name first, then the properties in the order given`() {
|
||||||
let event = Analytics.Event(
|
let event = Analytics.Event(
|
||||||
@@ -103,3 +119,4 @@ struct AnalyticsEventTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if Analytics
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@testable import Infrastructure
|
@testable import Infrastructure
|
||||||
@@ -133,3 +134,4 @@ struct AnalyticsTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -40,6 +40,7 @@ struct SocialCardTests {
|
|||||||
.init("2400", name: .imageWidth),
|
.init("2400", name: .imageWidth),
|
||||||
.init("1260", name: .imageHeight),
|
.init("1260", name: .imageHeight),
|
||||||
.init("An image.", name: .imageAlt),
|
.init("An image.", name: .imageAlt),
|
||||||
|
.init("An image.", name: .twitterImageAlt),
|
||||||
.init("summary_large_image", name: .twitter),
|
.init("summary_large_image", name: .twitter),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,7 @@ struct SocialCardTests {
|
|||||||
|
|
||||||
#expect(names.contains(.image))
|
#expect(names.contains(.image))
|
||||||
#expect(!names.contains(.imageAlt))
|
#expect(!names.contains(.imageAlt))
|
||||||
|
#expect(!names.contains(.twitterImageAlt))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,6 +89,7 @@ struct SocialCardTests {
|
|||||||
@Test
|
@Test
|
||||||
func `keys a tag by the attribute its name dictates`() {
|
func `keys a tag by the attribute its name dictates`() {
|
||||||
#expect(SocialCard.Tag.Name.twitter.attribute == .name)
|
#expect(SocialCard.Tag.Name.twitter.attribute == .name)
|
||||||
|
#expect(SocialCard.Tag.Name.twitterImageAlt.attribute == .name)
|
||||||
#expect(SocialCard.Tag.Name.title.attribute == .property)
|
#expect(SocialCard.Tag.Name.title.attribute == .property)
|
||||||
#expect(SocialCard.Tag("website", name: .type).attribute == .property)
|
#expect(SocialCard.Tag("website", name: .type).attribute == .property)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#if StructuredData
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -14,46 +15,80 @@ struct StructuredDataTests {
|
|||||||
@Test
|
@Test
|
||||||
func `derives the full payload from complete data`() {
|
func `derives the full payload from complete data`() {
|
||||||
let data = StructuredData(
|
let data = StructuredData(
|
||||||
name: "A Site",
|
organization: .init(
|
||||||
url: "https://site.example/",
|
name: "A Site",
|
||||||
logo: "https://site.example/logo.png",
|
url: "https://site.example/",
|
||||||
inLanguage: ["en", "nl"],
|
logo: "https://site.example/logo.png",
|
||||||
profiles: [
|
profiles: [
|
||||||
"https://social.example/a-site",
|
"https://social.example/a-site",
|
||||||
"https://videos.example/a-site",
|
"https://videos.example/a-site",
|
||||||
]
|
]
|
||||||
|
),
|
||||||
|
inLanguage: ["en", "nl"]
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
#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","# +
|
#"{"@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"]},"# +
|
#""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
|
@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(
|
let data = StructuredData(
|
||||||
name: "A Site",
|
organization: .init(
|
||||||
url: "https://site.example/",
|
name: "A Site",
|
||||||
alternateName: "The Site",
|
url: "https://site.example/",
|
||||||
description: "What the site is.",
|
alternateName: "The Site",
|
||||||
areaServed: "A City",
|
legalName: "A Site B.V.",
|
||||||
email: "hello@site.example"
|
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(
|
#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
|
@Test
|
||||||
func `references the organization's founder by identifier`() {
|
func `references the organization's founder by identifier`() {
|
||||||
let data = StructuredData(
|
let data = StructuredData(
|
||||||
name: "A Site",
|
organization: .init(
|
||||||
url: "https://site.example/",
|
name: "A Site",
|
||||||
profiles: ["https://social.example/a-site"],
|
url: "https://site.example/",
|
||||||
founder: "https://site.example/who#person"
|
profiles: ["https://social.example/a-site"],
|
||||||
|
founder: "https://site.example/who#person"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Last of the organization's properties, after the profiles.
|
// Last of the organization's properties, after the profiles.
|
||||||
@@ -65,8 +100,10 @@ struct StructuredDataTests {
|
|||||||
@Test
|
@Test
|
||||||
func `omits the founder when the data carries none`() {
|
func `omits the founder when the data carries none`() {
|
||||||
let data = StructuredData(
|
let data = StructuredData(
|
||||||
name: "A Site",
|
organization: .init(
|
||||||
url: "https://site.example/"
|
name: "A Site",
|
||||||
|
url: "https://site.example/"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(!data.payload.contains("founder"))
|
#expect(!data.payload.contains("founder"))
|
||||||
@@ -76,8 +113,10 @@ struct StructuredDataTests {
|
|||||||
func `derives the organization's identifier from the site URL`() {
|
func `derives the organization's identifier from the site URL`() {
|
||||||
let url = "https://site.example/"
|
let url = "https://site.example/"
|
||||||
let data = StructuredData(
|
let data = StructuredData(
|
||||||
name: "A Site",
|
organization: .init(
|
||||||
url: url
|
name: "A Site",
|
||||||
|
url: url
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Another page references the organization by this, so it must name the node the initializer actually builds.
|
// 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))""##))
|
#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
|
@Test
|
||||||
func `omits the properties of the facts minimal data does not carry`() {
|
func `omits the properties of the facts minimal data does not carry`() {
|
||||||
let data = StructuredData(
|
let data = StructuredData(
|
||||||
name: "A Site",
|
organization: .init(
|
||||||
url: "https://site.example/"
|
name: "A Site",
|
||||||
|
url: "https://site.example/"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
||||||
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# +
|
#"{"@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
|
@Test
|
||||||
func `escapes the values it embeds in the payload`() {
|
func `escapes the values it embeds in the payload`() {
|
||||||
let data = StructuredData(
|
let data = StructuredData(
|
||||||
name: #"A "Quoted" \ Site"#,
|
organization: .init(
|
||||||
url: "https://site.example/</script>"
|
name: #"A "Quoted" \ Site"#,
|
||||||
|
url: "https://site.example/</script>"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#))
|
#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 {
|
func `derives a payload that parses back to the facts it carries`() throws {
|
||||||
let name = "A \"Site\"\nwith \\ every <hazard>"
|
let name = "A \"Site\"\nwith \\ every <hazard>"
|
||||||
let data = StructuredData(
|
let data = StructuredData(
|
||||||
name: name,
|
organization: .init(
|
||||||
url: "https://site.example/</script>",
|
name: name,
|
||||||
logo: "https://site.example/logo.png",
|
url: "https://site.example/</script>",
|
||||||
profiles: ["https://social.example/a-site"]
|
logo: "https://site.example/logo.png",
|
||||||
|
profiles: ["https://social.example/a-site"]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
|
let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
|
||||||
@@ -215,3 +275,4 @@ struct StructuredDataTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -7,8 +7,10 @@ struct StubPage: Page {
|
|||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
|
#if Analytics
|
||||||
/// The analytics tracker rendered as a deferred script in the document head, or `nil` to omit it.
|
/// 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.
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
let assetVersion: String?
|
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.
|
/// The card rendered as link-preview tags in the document head, or `nil` to omit them.
|
||||||
let socialCard: SocialCard?
|
let socialCard: SocialCard?
|
||||||
|
|
||||||
|
#if StructuredData
|
||||||
/// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it.
|
/// 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.
|
/// The summary rendered in the document head, or `nil` to omit it.
|
||||||
let summary: String?
|
let summary: String?
|
||||||
@@ -37,29 +41,21 @@ struct StubPage: Page {
|
|||||||
/// default) to leave them unversioned.
|
/// default) to leave them unversioned.
|
||||||
/// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
|
/// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
|
||||||
/// to omit it.
|
/// 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`
|
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
|
||||||
/// (the default) to omit them.
|
/// (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)
|
/// - summary: the summary rendered in the document head, or `nil` (the default)
|
||||||
/// to omit it.
|
/// to omit it.
|
||||||
init(
|
init(
|
||||||
locale: Locale = .init(identifier: "en"),
|
locale: Locale = .init(identifier: "en"),
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
canonicalURL: String? = nil,
|
canonicalURL: String? = nil,
|
||||||
analytics: Analytics? = nil,
|
|
||||||
socialCard: SocialCard? = nil,
|
socialCard: SocialCard? = nil,
|
||||||
structuredData: StructuredData? = nil,
|
|
||||||
summary: String? = nil
|
summary: String? = nil
|
||||||
) {
|
) {
|
||||||
self.analytics = analytics
|
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.canonicalURL = canonicalURL
|
self.canonicalURL = canonicalURL
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
self.socialCard = socialCard
|
self.socialCard = socialCard
|
||||||
self.structuredData = structuredData
|
|
||||||
self.summary = summary
|
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version: 6.3
|
// swift-tools-version: 6.4
|
||||||
|
|
||||||
import PackageDescription
|
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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
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
|
## 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).
|
- 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.
|
- No package dependencies — Foundation and Synchronization only.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version: 6.3
|
// swift-tools-version: 6.4
|
||||||
|
|
||||||
import PackageDescription
|
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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
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
|
## 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).
|
- 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.
|
- Package dependencies: `hummingbird-fluent`, `fluent-postgres-driver`, `fluent-sqlite-driver`, `sql-kit`, `postgres-nio`, and `swift-nio-ssl`; the test target additionally depends on `swift-nio` for the TLS fallback tests' fake server.
|
||||||
@@ -136,7 +136,7 @@ public extension Listing {
|
|||||||
/// The grade or condition in a few words: `CGC 9.8` for a slab, `Raw · NM` for a raw issue, the condition alone for a collected edition.
|
/// 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 {
|
var gradeLabel: String {
|
||||||
if let grader, let grade {
|
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
|
return format == "single-issues" ? "Raw · \(condition)" : condition
|
||||||
|
|||||||
@@ -19,14 +19,18 @@ struct TLSTests {
|
|||||||
// With TLS disabled the client skips the `SSLRequest` and sends its startup message directly,
|
// With TLS disabled the client skips the `SSLRequest` and sends its startup message directly,
|
||||||
// which the fake server answers in plaintext.
|
// which the fake server answers in plaintext.
|
||||||
let server = try await PlaintextPostgresServer.start()
|
let server = try await PlaintextPostgresServer.start()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? await server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
let connection = try await connect(to: server, tls: .off)
|
let connection = try await connect(to: server, tls: .off)
|
||||||
|
|
||||||
let isConnected = !connection.isClosed
|
defer {
|
||||||
|
try? await connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
try await connection.close()
|
#expect(!connection.isClosed)
|
||||||
try await server.stop()
|
|
||||||
|
|
||||||
#expect(isConnected)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -34,14 +38,18 @@ struct TLSTests {
|
|||||||
// The fake server refuses the `SSLRequest`, so this connection can only succeed by downgrading
|
// 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.
|
// to plaintext — pinning the driver behavior the `prefer` posture relies on.
|
||||||
let server = try await PlaintextPostgresServer.start()
|
let server = try await PlaintextPostgresServer.start()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? await server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
let connection = try await connect(to: server, tls: .prefer)
|
let connection = try await connect(to: server, tls: .prefer)
|
||||||
|
|
||||||
let isConnected = !connection.isClosed
|
defer {
|
||||||
|
try? await connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
try await connection.close()
|
#expect(!connection.isClosed)
|
||||||
try await server.stop()
|
|
||||||
|
|
||||||
#expect(isConnected)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ struct ProbeTests {
|
|||||||
// ever be resolved by its deadline — without one, it would wait out the driver's own connect
|
// ever be resolved by its deadline — without one, it would wait out the driver's own connect
|
||||||
// timeout (10 seconds) instead.
|
// timeout (10 seconds) instead.
|
||||||
let server = try await SilentPostgresServer.start()
|
let server = try await SilentPostgresServer.start()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
try? await server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
let service = try Service(
|
let service = try Service(
|
||||||
driver: .postgres(
|
driver: .postgres(
|
||||||
.init(
|
.init(
|
||||||
@@ -91,7 +96,6 @@ struct ProbeTests {
|
|||||||
let elapsed = clock.now - start
|
let elapsed = clock.now - start
|
||||||
|
|
||||||
try await fluent.shutdown()
|
try await fluent.shutdown()
|
||||||
try await server.stop()
|
|
||||||
|
|
||||||
#expect(!isReachable)
|
#expect(!isReachable)
|
||||||
// Well past the 100-millisecond deadline to absorb scheduling noise, yet far below the driver's
|
// Well past the 100-millisecond deadline to absorb scheduling noise, yet far below the driver's
|
||||||
|
|||||||
@@ -69,17 +69,13 @@ struct ServiceTests {
|
|||||||
|
|
||||||
let fluent = service()
|
let fluent = service()
|
||||||
|
|
||||||
do {
|
defer {
|
||||||
let database = try #require(fluent.db() as? any SQLDatabase)
|
|
||||||
|
|
||||||
try await database.raw("SELECT 1").run()
|
|
||||||
} catch {
|
|
||||||
try? await fluent.shutdown()
|
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")
|
@Test("in-memory: migrate, seed, read back")
|
||||||
@@ -124,25 +120,21 @@ private extension ServiceTests {
|
|||||||
|
|
||||||
let fluent = service()
|
let fluent = service()
|
||||||
|
|
||||||
do {
|
defer {
|
||||||
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 {
|
|
||||||
try? await fluent.shutdown()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version: 6.3
|
// swift-tools-version: 6.4
|
||||||
|
|
||||||
import PackageDescription
|
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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
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
|
## 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).
|
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||||
- No package dependencies — Foundation only.
|
- No package dependencies — Foundation only.
|
||||||
+12
-12
@@ -4,12 +4,11 @@
|
|||||||
FROM node:22-alpine AS assets
|
FROM node:22-alpine AS assets
|
||||||
|
|
||||||
ARG ESBUILD_VERSION=0.28.1
|
ARG ESBUILD_VERSION=0.28.1
|
||||||
ARG OXIPNG_VERSION=9.1.5
|
ARG OXIPNG_VERSION=9.1
|
||||||
ARG SVGO_VERSION=4.0.2
|
ARG SVGO_VERSION=4.0.2
|
||||||
|
|
||||||
# Install the minifiers in their own layer, so they are cached across asset changes.
|
# 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
|
# The oxipng pin stops at major.minor: Alpine drops a package's old builds, so a patch pin fails on its next release.
|
||||||
# image advances.
|
|
||||||
RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \
|
RUN apk add --no-cache "oxipng=~${OXIPNG_VERSION}" \
|
||||||
&& npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}"
|
&& npm install --global "esbuild@${ESBUILD_VERSION}" "svgo@${SVGO_VERSION}"
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ COPY --from=assets /static /
|
|||||||
# ================================
|
# ================================
|
||||||
# Build image
|
# Build image
|
||||||
# ================================
|
# ================================
|
||||||
FROM swift:6.3-noble AS build
|
FROM swift:6.4-noble AS build
|
||||||
|
|
||||||
# Install OS updates
|
# Install OS updates
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||||
@@ -66,12 +65,13 @@ COPY ./Services/Website/Sources ./Services/Website/Sources
|
|||||||
|
|
||||||
# Build the application, with optimizations, with static linking, and using jemalloc.
|
# 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
|
# SPM validates every target path, test targets and their resources included, so those directories must exist. They are
|
||||||
# executable product is built, so those directories have to exist. They are created empty instead of copied: the test
|
# created empty, not copied, so an edited test does not invalidate this layer and the Tests/DB bind mount stays out.
|
||||||
# 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.
|
# The native build system works around https://github.com/swiftlang/swift-package-manager/issues/10592: Swift 6.4's
|
||||||
RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
|
# default one fails to link a static Foundation.
|
||||||
&& swift build --package-path ./Services/Website -c release \
|
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" \
|
--product "Website" \
|
||||||
--static-swift-stdlib \
|
--static-swift-stdlib \
|
||||||
-Xlinker -ljemalloc
|
-Xlinker -ljemalloc
|
||||||
@@ -80,14 +80,14 @@ RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
|
|||||||
WORKDIR /staging
|
WORKDIR /staging
|
||||||
|
|
||||||
# Copy main executable to staging area, without its debug sections
|
# 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
|
&& strip --strip-debug ./Website
|
||||||
|
|
||||||
# Copy static swift backtracer binary to staging area
|
# Copy static swift backtracer binary to staging area
|
||||||
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
|
||||||
|
|
||||||
# Copy resources bundled by SPM to staging area
|
# 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
|
# Create the static files directory (served by FileMiddleware) and fill it with the minified copies from the assets stage
|
||||||
RUN mkdir -p ./Resources/Static
|
RUN mkdir -p ./Resources/Static
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// swift-tools-version: 6.3
|
// swift-tools-version: 6.4
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
@@ -21,7 +21,11 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(
|
.package(
|
||||||
path: "../../Packages/Infrastructure"
|
path: "../../Packages/Infrastructure",
|
||||||
|
traits: [
|
||||||
|
"Analytics",
|
||||||
|
"StructuredData",
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.package(
|
.package(
|
||||||
path: "../../Packages/Localization"
|
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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
- 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
|
## 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.
|
- 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).
|
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ Four of those are page-authored and optional. `IndexPage` supplies `canonicalURL
|
|||||||
| `summary` | `<meta name="description">` | The page's one-line description. |
|
| `summary` | `<meta name="description">` | The page's one-line description. |
|
||||||
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). |
|
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). |
|
||||||
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
|
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
|
||||||
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
|
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. Requires Infrastructure's `StructuredData` trait, which `Package.swift` enables. |
|
||||||
|
|
||||||
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
|
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ The test sources are deliberately absent from the image. SPM validates the path
|
|||||||
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
|
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker buildx imagetools inspect swift:6.3-noble --format '{{.Manifest.Digest}}'
|
docker buildx imagetools inspect swift:6.4-noble --format '{{.Manifest.Digest}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.
|
Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.
|
||||||
|
|||||||
@@ -40,3 +40,32 @@ if (listingForm) {
|
|||||||
listingForm.addEventListener("change", apply);
|
listingForm.addEventListener("change", apply);
|
||||||
apply();
|
apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Elements tagged `data-view-event` are reported the first time they are fully on screen, when the tracker is present.
|
||||||
|
(() => {
|
||||||
|
const elements = document.querySelectorAll("[data-view-event]");
|
||||||
|
|
||||||
|
if (!window.umami || elements.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = "data-view-event-";
|
||||||
|
|
||||||
|
// Each `data-view-event-<name>` attribute is a property, as `data-umami-event-<name>` is for a click.
|
||||||
|
const properties = (element) => Object.fromEntries(
|
||||||
|
[...element.attributes]
|
||||||
|
.filter((attribute) => attribute.name.startsWith(prefix))
|
||||||
|
.map((attribute) => [attribute.name.slice(prefix.length), attribute.value])
|
||||||
|
);
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
window.umami.track(entry.target.getAttribute("data-view-event"), properties(entry.target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { threshold: 1 });
|
||||||
|
|
||||||
|
elements.forEach((element) => observer.observe(element));
|
||||||
|
})();
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Int {
|
extension Int {
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// The amount of euro cents as a price: whole euros bare (`€240`), anything else to the cent (`€12.50`).
|
/// The amount of euro cents as a price: whole euros bare (`€240`), anything else to the cent (`€12.50`).
|
||||||
var euros: String {
|
var euros: String {
|
||||||
isMultiple(of: 100)
|
let cents = self % 100
|
||||||
|
|
||||||
|
return isMultiple(of: 100)
|
||||||
? "€\(self / 100)"
|
? "€\(self / 100)"
|
||||||
: String(format: "€%d.%02d", self / 100, self % 100)
|
: "€\(self / 100).\(cents < 10 ? "0" : "")\(cents)"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ extension Shop {
|
|||||||
|
|
||||||
/// The shop's standing in a line: where it is, since when, and how it is rated.
|
/// The shop's standing in a line: where it is, since when, and how it is rated.
|
||||||
var standing: String {
|
var standing: String {
|
||||||
"\(location) · selling on CCN since \(sinceYear) · \(String(format: "%.1f", rating)) from \(ratedSales) rated sales"
|
"\(location) · selling on CCN since \(sinceYear) · \(rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))) from \(ratedSales) rated sales"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ extension Double {
|
|||||||
|
|
||||||
/// The grade to one decimal place, e.g. `9.8`.
|
/// The grade to one decimal place, e.g. `9.8`.
|
||||||
var gradeLabel: String {
|
var gradeLabel: String {
|
||||||
String(format: "%.1f", self)
|
formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,8 @@ extension Page {
|
|||||||
siteMetadata
|
siteMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
/// The `hreflang` alternates tying the page's language editions together, or nothing without an origin — the annotations require absolute URLs.
|
/// The `hreflang` alternates tying the page's language editions together, or nothing without an origin — the annotations require absolute URLs.
|
||||||
///
|
///
|
||||||
/// Nothing is emitted for a single-language site either: a set naming one edition tells a search engine nothing it cannot already see.
|
/// Nothing is emitted for a single-language site either: a set naming one edition tells a search engine nothing it cannot already see.
|
||||||
@@ -91,6 +93,17 @@ extension Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The locales of the page's other language editions, in ``ogLocale``'s form.
|
||||||
|
/// - Parameter languages: the languages the page is published in; every catalog language by default.
|
||||||
|
/// - Returns: one locale per other language; empty for a single-language page.
|
||||||
|
func ogAlternateLocales(
|
||||||
|
languages: [Language] = Language.all
|
||||||
|
) -> [String] {
|
||||||
|
languages
|
||||||
|
.filter { $0.identifier != lang }
|
||||||
|
.map { Self.ogLocales[$0.identifier] ?? $0.identifier }
|
||||||
|
}
|
||||||
|
|
||||||
/// The ``preloadedFonts`` links, then the icon, manifest, and theme colour metadata shared by every page of the website.
|
/// The ``preloadedFonts`` links, then the icon, manifest, and theme colour metadata shared by every page of the website.
|
||||||
///
|
///
|
||||||
/// A preload URL must match the stylesheet's `@font-face` source exactly — unversioned, with `crossorigin` — or the browser fetches the
|
/// A preload URL must match the stylesheet's `@font-face` source exactly — unversioned, with `crossorigin` — or the browser fetches the
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ private extension ComparePage {
|
|||||||
of copy: Listing
|
of copy: Listing
|
||||||
) -> [(term: String, value: String)] {
|
) -> [(term: String, value: String)] {
|
||||||
let customs = copy.shop.shipsFromEU ? "" : " · customs may apply"
|
let customs = copy.shop.shipsFromEU ? "" : " · customs may apply"
|
||||||
|
let rating = copy.shop.rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en")))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
("Asking", copy.priceCents.euros + (copy.acceptsOffers ? " · accepts offers" : "")),
|
("Asking", copy.priceCents.euros + (copy.acceptsOffers ? " · accepts offers" : "")),
|
||||||
@@ -187,7 +188,7 @@ private extension ComparePage {
|
|||||||
("Declared defects", copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")),
|
("Declared defects", copy.defects.isEmpty ? "None declared" : copy.defects.joined(separator: "; ")),
|
||||||
("Ship to NL", "\(copy.shippingCents.euros) tracked · \(copy.shop.handlingDays)-day handling\(customs)"),
|
("Ship to NL", "\(copy.shippingCents.euros) tracked · \(copy.shop.handlingDays)-day handling\(customs)"),
|
||||||
("Shop", "\(copy.shop.name) · \(copy.shop.location)"),
|
("Shop", "\(copy.shop.name) · \(copy.shop.location)"),
|
||||||
("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(String(format: "%.1f", copy.shop.rating)) · \(copy.shop.returnsNote)"),
|
("Rated sales", copy.shop.ratedSales == 0 ? "None yet" : "\(copy.shop.ratedSales) · \(rating) · \(copy.shop.returnsNote)"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ extension SellerDashboardPage: Page {
|
|||||||
tile("\(offers.count)", "Open offers", offers.first.map { "next: \($0.expiresAt.countdown(from: now))" })
|
tile("\(offers.count)", "Open offers", offers.first.map { "next: \($0.expiresAt.countdown(from: now))" })
|
||||||
tile("\(viewer?.unreadMessages ?? 0)", "Unread conversations", nil)
|
tile("\(viewer?.unreadMessages ?? 0)", "Unread conversations", nil)
|
||||||
tile("\(sales.count)", "Sales", "\(sales.count { $0.next == .sent }) awaiting your dispatch")
|
tile("\(sales.count)", "Sales", "\(sales.count { $0.next == .sent }) awaiting your dispatch")
|
||||||
tile(String(format: "%.1f", shop.rating), "Rating", "\(shop.ratedSales) rated sales")
|
tile(shop.rating.formatted(.number.precision(.fractionLength(1)).locale(.init(identifier: "en"))), "Rating", "\(shop.ratedSales) rated sales")
|
||||||
}
|
}
|
||||||
section(.class("card sell__panel")) {
|
section(.class("card sell__panel")) {
|
||||||
div(.class("section-head")) {
|
div(.class("section-head")) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public struct NoIndexMiddleware<Context: RequestContext>: RouterMiddleware {
|
|||||||
public func handle(
|
public func handle(
|
||||||
_ request: Request,
|
_ request: Request,
|
||||||
context: Context,
|
context: Context,
|
||||||
next: (Request, Context) async throws -> Response
|
next: @concurrent (Request, Context) async throws -> Response
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
do {
|
do {
|
||||||
var response = try await next(request, context)
|
var response = try await next(request, context)
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ struct PageDefaultsTests {
|
|||||||
|
|
||||||
// MARK: Method tests
|
// MARK: Method tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lists no alternate social card locales for a single language`() {
|
||||||
|
#expect(StubPage().ogAlternateLocales(languages: [.default]).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `lists the social card locale of every other language`() {
|
||||||
|
#expect(StubPage().ogAlternateLocales(languages: Self.languages) == ["nl"])
|
||||||
|
#expect(StubPage(locale: .init(identifier: "nl")).ogAlternateLocales(languages: Self.languages) == ["en_US"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `renders no language alternates without an origin`() {
|
func `renders no language alternates without an origin`() {
|
||||||
let html = StubPage().languageAlternates(
|
let html = StubPage().languageAlternates(
|
||||||
|
|||||||
@@ -119,24 +119,22 @@ struct CollectorPagesTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `series lists an issue's copies cheapest delivered first, ready to compare`() {
|
func `series lists an issue's copies cheapest delivered first, ready to compare`() throws {
|
||||||
let html = SeriesPage(
|
let html = SeriesPage(
|
||||||
series: Self.nightward,
|
series: Self.nightward,
|
||||||
listings: [.stub(), .stub(slug: "cheap", priceCents: 9_000, defects: ["Foxing / staining"])],
|
listings: [.stub(), .stub(slug: "cheap", priceCents: 9_000, defects: ["Foxing / staining"])],
|
||||||
issue: 1,
|
issue: 1,
|
||||||
viewer: .init(displayName: "Mara")
|
viewer: .init(displayName: "Mara")
|
||||||
).render()
|
).render()
|
||||||
let cheap = try? #require(html.range(of: #"value="cheap""#))
|
let cheap = try #require(html.range(of: #"value="cheap""#))
|
||||||
let dear = try? #require(html.range(of: #"value="nightward-1""#))
|
let dear = try #require(html.range(of: #"value="nightward-1""#))
|
||||||
|
|
||||||
#expect(html.contains("Copies of #1 — 2 listed"))
|
#expect(html.contains("Copies of #1 — 2 listed"))
|
||||||
#expect(html.contains(#"action="/compare" method="get""#))
|
#expect(html.contains(#"action="/compare" method="get""#))
|
||||||
#expect(html.contains("Foxing / staining"))
|
#expect(html.contains("Foxing / staining"))
|
||||||
#expect(html.contains(#"href="/wants/new?title=Nightward%20%232">None listed · alert me"#))
|
#expect(html.contains(#"href="/wants/new?title=Nightward%20%232">None listed · alert me"#))
|
||||||
|
|
||||||
if let cheap, let dear {
|
#expect(cheap.lowerBound < dear.lowerBound)
|
||||||
#expect(cheap.lowerBound < dear.lowerBound)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -153,23 +151,21 @@ struct CollectorPagesTests {
|
|||||||
// MARK: ComparePage tests
|
// MARK: ComparePage tests
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `comparison orders copies by what they cost delivered, and flags a customs border`() {
|
func `comparison orders copies by what they cost delivered, and flags a customs border`() throws {
|
||||||
let abroad = Shop(
|
let abroad = Shop(
|
||||||
slug: "longbox", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024, isVerified: false,
|
slug: "longbox", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024, isVerified: false,
|
||||||
rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4, shipsNote: "", returnsNote: "returns by agreement", about: ""
|
rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4, shipsNote: "", returnsNote: "returns by agreement", about: ""
|
||||||
)
|
)
|
||||||
let html = ComparePage(copies: [.stub(), .stub(slug: "abroad", shop: abroad, priceCents: 15_000)]).render()
|
let html = ComparePage(copies: [.stub(), .stub(slug: "abroad", shop: abroad, priceCents: 15_000)]).render()
|
||||||
let first = try? #require(html.range(of: "€156"))
|
let first = try #require(html.range(of: "€156"))
|
||||||
let second = try? #require(html.range(of: "€246"))
|
let second = try #require(html.range(of: "€246"))
|
||||||
|
|
||||||
#expect(html.contains("customs may apply"))
|
#expect(html.contains("customs may apply"))
|
||||||
#expect(html.components(separatedBy: "customs may apply").count - 1 == 1)
|
#expect(html.components(separatedBy: "customs may apply").count - 1 == 1)
|
||||||
#expect(html.contains(#"href="/sign-in?return=/listing/abroad">Buy now"#))
|
#expect(html.contains(#"href="/sign-in?return=/listing/abroad">Buy now"#))
|
||||||
#expect(html.contains("58 · 4.6 · returns by agreement"))
|
#expect(html.contains("58 · 4.6 · returns by agreement"))
|
||||||
|
|
||||||
if let first, let second {
|
#expect(first.lowerBound < second.lowerBound)
|
||||||
#expect(first.lowerBound < second.lowerBound)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+32
-40
@@ -43,29 +43,25 @@ struct HealthControllerTests {
|
|||||||
)
|
)
|
||||||
let fluent = service()
|
let fluent = service()
|
||||||
|
|
||||||
do {
|
defer {
|
||||||
try await app(
|
|
||||||
probe: Probe(fluent: fluent)
|
|
||||||
).test(.router) { client in
|
|
||||||
try await client.execute(
|
|
||||||
uri: "/health/ready",
|
|
||||||
method: .get
|
|
||||||
) { response in
|
|
||||||
let body = String(buffer: response.body)
|
|
||||||
|
|
||||||
#expect(response.status == .ok)
|
|
||||||
#expect(response.headers[.contentType] == "application/json")
|
|
||||||
#expect(body == #"{"status":"ready"}"#)
|
|
||||||
#expect(response.headers[.robotsTag] == "noindex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
try? await fluent.shutdown()
|
try? await fluent.shutdown()
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await fluent.shutdown()
|
try await app(
|
||||||
|
probe: Probe(fluent: fluent)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/health/ready",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .ok)
|
||||||
|
#expect(response.headers[.contentType] == "application/json")
|
||||||
|
#expect(body == #"{"status":"ready"}"#)
|
||||||
|
#expect(response.headers[.robotsTag] == "noindex")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -89,29 +85,25 @@ struct HealthControllerTests {
|
|||||||
)
|
)
|
||||||
let fluent = service()
|
let fluent = service()
|
||||||
|
|
||||||
do {
|
defer {
|
||||||
try await app(
|
|
||||||
probe: Probe(fluent: fluent)
|
|
||||||
).test(.router) { client in
|
|
||||||
try await client.execute(
|
|
||||||
uri: "/health/ready",
|
|
||||||
method: .get
|
|
||||||
) { response in
|
|
||||||
let body = String(buffer: response.body)
|
|
||||||
|
|
||||||
#expect(response.status == .serviceUnavailable)
|
|
||||||
#expect(response.headers[.contentType] == "application/json")
|
|
||||||
#expect(body == #"{"status":"unavailable"}"#)
|
|
||||||
#expect(response.headers[.robotsTag] == "noindex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
try? await fluent.shutdown()
|
try? await fluent.shutdown()
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await fluent.shutdown()
|
try await app(
|
||||||
|
probe: Probe(fluent: fluent)
|
||||||
|
).test(.router) { client in
|
||||||
|
try await client.execute(
|
||||||
|
uri: "/health/ready",
|
||||||
|
method: .get
|
||||||
|
) { response in
|
||||||
|
let body = String(buffer: response.body)
|
||||||
|
|
||||||
|
#expect(response.status == .serviceUnavailable)
|
||||||
|
#expect(response.headers[.contentType] == "application/json")
|
||||||
|
#expect(body == #"{"status":"unavailable"}"#)
|
||||||
|
#expect(response.headers[.robotsTag] == "noindex")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ struct PhotoStoreTests {
|
|||||||
])
|
])
|
||||||
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
|
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
|
||||||
file: ByteBuffer
|
file: ByteBuffer
|
||||||
) throws {
|
) {
|
||||||
try Self.withFolder { folder in
|
Self.withFolder { folder in
|
||||||
#expect(throws: PhotoStore.Refusal.notAnImage) {
|
#expect(throws: PhotoStore.Refusal.notAnImage) {
|
||||||
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
|
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
|
||||||
}
|
}
|
||||||
@@ -51,8 +51,8 @@ struct PhotoStoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func `turns down too many photos, and one too large`() throws {
|
func `turns down too many photos, and one too large`() {
|
||||||
try Self.withFolder { folder in
|
Self.withFolder { folder in
|
||||||
let store = PhotoStore(staticFilesPath: folder)
|
let store = PhotoStore(staticFilesPath: folder)
|
||||||
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))
|
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user