Project updates from Template #1
+1
-1
@@ -10,7 +10,7 @@
|
||||
**/.build
|
||||
**/.swiftpm
|
||||
|
||||
# Test sources
|
||||
# Local database data directory.
|
||||
**/Tests/DB
|
||||
|
||||
# Xcode project (not used by the Linux build)
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
# Infrastructure
|
||||
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the **Loud** services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized HTML responses, and the page and asset scaffolding.
|
||||
The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) toolkit the platform's services build on: declarative routing, hardened HTTP middlewares, pre-rendered localized responses, and page and asset scaffolding.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware` (negotiating from a `lang` query parameter, then a leading path segment, then `Accept-Language`), `NotFoundMiddleware` |
|
||||
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||
| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives |
|
||||
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies |
|
||||
| Analytics | `Analytics`, the tracker script `Attribute`s it derives, and the `origin` its preconnect hint targets |
|
||||
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
||||
| Link previews | `SocialCard`, its `locale` and the `alternateLocales` of its other language editions, its `Image` and `Style`, and the `Tag` meta tags it derives |
|
||||
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair |
|
||||
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to any attribute-bearing HTML or SVG tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
|
||||
| Responses | `CachedHTMLResponse`, and `LocalizedHTMLCollectionResponse` rendering one of them per catalog language |
|
||||
| Contexts | `LocalizedRequestContext` |
|
||||
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
||||
|
||||
## Design rules
|
||||
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. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure building a page for a locale, and a `Page` conformer's `metadata` plus its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive fully formed and absolute; composing them stays with the page.
|
||||
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready representation — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — and `Page` applies it without knowing the vocabulary. Page ``scripts`` and the tracker render as `defer`red head tags, with a `preconnect` hint for the tracker's cross-origin host.
|
||||
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `*+Defaults` extensions are the pattern. The open schema.org vocabularies extend the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, and a service adds its own.
|
||||
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||
The package holds only what every service can reuse. Anything a service owns is injected, never referenced.
|
||||
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page. The same holds for request semantics: only the caller knows whether it negotiates the language or pins it by route, so `variesOnAcceptLanguage:` declares whether the responses carry `Vary: Accept-Language`.
|
||||
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready form — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — which `Page` applies without knowing the vocabulary. A page's `scripts` and the tracker render as `defer`red head tags, the tracker preceded by a `preconnect` to its cross-origin host.
|
||||
- **Nodes are joined by `@id`, not repetition.** A node another page must point at gets its identifier from a helper rather than a hand-spelled fragment — `organizationID(forSiteURL:)` names the node the site-wide initializer builds, and that initializer's `founder` takes such an identifier back. A service adds the helper for any node it owns, so neither side can drift.
|
||||
- **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`.
|
||||
|
||||
## Layout
|
||||
Sources are split by visibility, then by kind, one type per file:
|
||||
@@ -28,12 +29,12 @@ Sources/
|
||||
├── Public/ public API
|
||||
│ ├── Builders/ RouteCollectionBuilder
|
||||
│ ├── Enumerations/ AssetExtension
|
||||
│ ├── Extensions/ addController, plus the default header names and values
|
||||
│ ├── Extensions/ addController and Analytics.Event tagging, plus the default header names, rate limits, and header values
|
||||
│ ├── Methods/ FingerprintAssets
|
||||
│ ├── Middlewares/ the five HTTP middlewares
|
||||
│ ├── Middlewares/ the seven HTTP middlewares
|
||||
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
||||
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
||||
│ └── Types/ Analytics, SocialCard, StructuredData (the latter two nesting their own types in SocialCard/ and StructuredData/)
|
||||
│ └── Types/ Analytics, SocialCard, StructuredData — each nesting its own types in a folder of that name
|
||||
└── Internal/
|
||||
├── Extensions/ implementation details (the String separators)
|
||||
└── Types/ implementation details (FNV1aHash)
|
||||
@@ -44,8 +45,8 @@ Tests/
|
||||
```
|
||||
|
||||
## Testing
|
||||
Every suite carries a tag for the kind of API it exercises — `.asset`, `.extension`, `.middleware`, `.protocol`, 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 — `.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.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- 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 declare no UI platforms.
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
/// A file extension used by an ``Asset``.
|
||||
///
|
||||
/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its file name when resolving paths.
|
||||
public enum AssetExtension: String, Sendable {
|
||||
public enum AssetExtension: String, CaseIterable, Sendable {
|
||||
/// A Cascading Style Sheets file.
|
||||
case css
|
||||
/// A JavaScript file.
|
||||
case js
|
||||
/// A Portable Network Graphics image.
|
||||
case png
|
||||
/// A Windows icon image.
|
||||
case ico
|
||||
/// A JPEG image.
|
||||
case jpg
|
||||
/// A JavaScript file.
|
||||
case js
|
||||
/// An MPEG-4 video.
|
||||
case mp4
|
||||
/// A Portable Network Graphics image.
|
||||
case png
|
||||
/// A Scalable Vector Graphics image.
|
||||
case svg
|
||||
/// A plain text file.
|
||||
case txt
|
||||
/// A web application manifest file.
|
||||
case webmanifest
|
||||
/// A WebP image.
|
||||
case webp
|
||||
/// A Web Open Font Format 2 font.
|
||||
case woff2
|
||||
/// An Extensible Markup Language file.
|
||||
case xml
|
||||
}
|
||||
@@ -30,12 +38,16 @@ public extension AssetExtension {
|
||||
var contentType: String {
|
||||
switch self {
|
||||
case .css: "text/css"
|
||||
case .js: "text/javascript"
|
||||
case .png: "image/png"
|
||||
case .ico: "image/vnd.microsoft.icon"
|
||||
case .jpg: "image/jpeg"
|
||||
case .js: "text/javascript"
|
||||
case .mp4: "video/mp4"
|
||||
case .png: "image/png"
|
||||
case .svg: "image/svg+xml"
|
||||
case .txt: "text/plain"
|
||||
case .webmanifest: "application/manifest+json"
|
||||
case .webp: "image/webp"
|
||||
case .woff2: "font/woff2"
|
||||
case .xml: "application/xml"
|
||||
}
|
||||
}
|
||||
@@ -45,6 +57,10 @@ public extension AssetExtension {
|
||||
switch self {
|
||||
case .css: "css"
|
||||
case .js: "js"
|
||||
case .jpg,
|
||||
.webp: "img"
|
||||
case .mp4: "video"
|
||||
case .woff2: "font"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Elementary
|
||||
|
||||
public extension Analytics.Event {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The event's ``attributes`` as attributes of the tag reporting it.
|
||||
///
|
||||
/// ```swift
|
||||
/// a(.href(url)) { "Listen" }
|
||||
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
|
||||
/// ```
|
||||
///
|
||||
/// - Returns: one HTML attribute per event attribute, applied verbatim.
|
||||
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
|
||||
attributes.map {
|
||||
.custom(
|
||||
name: $0.name,
|
||||
value: $0.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,8 +5,12 @@ public extension HTTPField.Name {
|
||||
static let permissionsPolicy = Self("Permissions-Policy")!
|
||||
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let referrerPolicy = Self("Referrer-Policy")!
|
||||
/// The `X-Robots-Tag` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let robotsTag = Self("X-Robots-Tag")!
|
||||
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let frameOptions = Self("X-Frame-Options")!
|
||||
/// The `X-Forwarded-For` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let xForwardedFor = Self("X-Forwarded-For")!
|
||||
/// The `X-Forwarded-Proto` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let xForwardedProto = Self("X-Forwarded-Proto")!
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
|
||||
/// Answers a request forwarded over plain HTTP with `301 Moved Permanently` to the same path on the site's HTTPS origin.
|
||||
///
|
||||
/// Behind a TLS-terminating proxy the server only sees plain HTTP, so the visitor's scheme survives only in the `X-Forwarded-Proto` header the proxy
|
||||
/// sets. Redirecting collapses the `http://` and `https://` copies of every page onto one address — what a search engine consolidates a site's signals
|
||||
/// against.
|
||||
///
|
||||
/// Three deliberate choices:
|
||||
/// - **`301`, not `302`.** A temporary redirect tells a crawler the HTTP address is the canonical one, so the HTTP URLs stay indexed. Browsers cache a
|
||||
/// `301` for a long time, so the target must be settled before enabling this.
|
||||
/// - **The target comes from ``Configuration/origin``, not the request's `Host` header.** A client cannot steer a configured origin, so it can neither
|
||||
/// aim the redirect elsewhere nor poison a shared cache with the result.
|
||||
/// - **`/.well-known/` is exempt.** A certificate authority looks there for an ACME challenge over plain HTTP; redirecting it away breaks renewal, and
|
||||
/// the breakage surfaces only when the certificate expires.
|
||||
///
|
||||
/// - Note: `Context` is the request context the middleware is resolved against.
|
||||
public struct HTTPSRedirectMiddleware<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The origin the redirects point at, and whether the forwarded-protocol header is trusted.
|
||||
private let configuration: Configuration
|
||||
|
||||
/// Whether the middleware redirects at all, resolved once at initialization.
|
||||
///
|
||||
/// An untrusted header disables it, and so does an origin that is not itself HTTPS: redirecting to a plain-HTTP origin would loop.
|
||||
private let isEnabled: Bool
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an HTTPS-redirect middleware.
|
||||
/// - Parameter configuration: the origin the redirects point at, and whether the forwarded-protocol header is trusted.
|
||||
public init(
|
||||
configuration: Configuration
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.isEnabled = configuration.trustForwardedProto
|
||||
&& configuration.origin.hasPrefix(.httpsPrefix)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension HTTPSRedirectMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Redirects a request forwarded over plain HTTP, and passes every other request down the chain.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the redirect, or the downstream response.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
guard
|
||||
isEnabled,
|
||||
isForwardedOverHTTP(request),
|
||||
!isWellKnown(request)
|
||||
else {
|
||||
return try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
var response = Response(status: .movedPermanently)
|
||||
|
||||
response.headers[.location] = configuration.origin + target(of: request)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HTTPSRedirectMiddleware {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Whether the proxy reported the visitor's request as plain HTTP.
|
||||
///
|
||||
/// Only the leftmost entry is read: a chain of proxies appends to the header, so that entry is the scheme the visitor used. The comparison is
|
||||
/// case-insensitive, the header carrying a scheme name rather than a fixed-case token.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: `true` when the header's first entry names plain HTTP.
|
||||
func isForwardedOverHTTP(
|
||||
_ request: Request
|
||||
) -> Bool {
|
||||
request.headers[.xForwardedProto]?
|
||||
.split(separator: ",")
|
||||
.first?
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.lowercased() == .httpScheme
|
||||
}
|
||||
|
||||
/// Whether the request addresses the reserved well-known space, which is left on plain HTTP so an ACME challenge stays reachable.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: `true` when the path falls inside the well-known space.
|
||||
func isWellKnown(
|
||||
_ request: Request
|
||||
) -> Bool {
|
||||
request.uri.path.hasPrefix(.wellKnownPrefix)
|
||||
}
|
||||
|
||||
/// The path and query the redirect preserves, so a visitor lands on the address they asked for.
|
||||
///
|
||||
/// Rebuilt from the parsed components rather than the raw request target: a target in absolute form would append a second origin to the first.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: the path, followed by the query when the request carries one.
|
||||
func target(
|
||||
of request: Request
|
||||
) -> String {
|
||||
guard
|
||||
let query = request.uri.query,
|
||||
!query.isEmpty
|
||||
else {
|
||||
return request.uri.path
|
||||
}
|
||||
|
||||
return request.uri.path + .querySeparator + query
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
extension HTTPSRedirectMiddleware {
|
||||
/// The origin an ``HTTPSRedirectMiddleware`` redirects to, and whether it trusts the header telling it to.
|
||||
public struct Configuration: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The public origin the redirects point at (scheme and host, no trailing slash).
|
||||
///
|
||||
/// An origin that is not itself HTTPS disables the middleware rather than redirecting into a loop.
|
||||
public let origin: String
|
||||
|
||||
/// Whether the visitor's scheme is read from the `X-Forwarded-Proto` header.
|
||||
///
|
||||
/// This is the middleware's only trigger. Enable it solely behind a reverse proxy that sets the header: on a directly reachable server the header
|
||||
/// is client-supplied, so it stays off by default.
|
||||
public let trustForwardedProto: Bool
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an HTTPS-redirect configuration.
|
||||
/// - Parameters:
|
||||
/// - origin: the public origin the redirects point at (scheme and host, no trailing slash).
|
||||
/// - trustForwardedProto: whether the visitor's scheme is read from the `X-Forwarded-Proto` header. Defaults to `false`.
|
||||
public init(
|
||||
origin: String,
|
||||
trustForwardedProto: Bool = false
|
||||
) {
|
||||
self.origin = origin
|
||||
self.trustForwardedProto = trustForwardedProto
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
/// The forwarded-protocol value naming a request made over plain HTTP.
|
||||
static let httpScheme = "http"
|
||||
/// The scheme prefix a redirect target must carry for the redirect to terminate.
|
||||
static let httpsPrefix = "https://"
|
||||
/// The delimiter placed between a redirect target's path and its query.
|
||||
static let querySeparator = "?"
|
||||
/// The reserved path prefix left on plain HTTP, so an ACME challenge stays reachable.
|
||||
static let wellKnownPrefix = "/.well-known/"
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import Localization
|
||||
|
||||
/// Resolves the visitor's preferred language and records it on the request context.
|
||||
///
|
||||
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported
|
||||
/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``.
|
||||
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `lang` query parameter, its path, and its
|
||||
/// `Accept-Language` header, negotiates the best supported match (falling back to the default language), and stores it on the context's
|
||||
/// ``LocalizedRequestContext/language``.
|
||||
///
|
||||
/// The request is otherwise passed through untouched — the URL and routing are not affected — so each page is served at its existing path and varies its
|
||||
/// content by header.
|
||||
/// The query parameter is a deliberate override; failing that, a leading path segment naming a supported language pins it, so an unrouted path under
|
||||
/// a language's prefix — its not-found page — answers in that language. Values naming no supported language are ignored, leaving the header. The
|
||||
/// request is passed through untouched — the path and routing are unaffected.
|
||||
///
|
||||
/// A site whose page routes pin their language by URL consults this only for responses that belong to no URL — in practice, its not-found page.
|
||||
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -49,7 +53,13 @@ extension LocalizationMiddleware: RouterMiddleware {
|
||||
) async throws -> Response {
|
||||
var context = context
|
||||
|
||||
// The deliberate query override first; failing that, the leading path segment, so a language's whole URL
|
||||
// prefix — routed or not — answers in its language.
|
||||
let requested = request.uri.queryParameters[.Parameter.language].map(String.init)
|
||||
?? request.uri.path.split(separator: "/").first.map(String.init)
|
||||
|
||||
context.language = negotiate(
|
||||
requested: requested,
|
||||
acceptLanguage: request.headers[.acceptLanguage]
|
||||
)
|
||||
|
||||
@@ -57,3 +67,13 @@ extension LocalizationMiddleware: RouterMiddleware {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private extension Substring {
|
||||
/// A namespace for the query parameters the middleware reads.
|
||||
enum Parameter {
|
||||
/// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path.
|
||||
static let language: Substring = "lang"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import Hummingbird
|
||||
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
|
||||
/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by
|
||||
/// ``LocalizationMiddleware``, falling back to the default language.
|
||||
///
|
||||
/// Its responses declare `Vary: Accept-Language`: where the page routes pin their language by URL, this is the one responder that negotiates.
|
||||
/// An unrouted path names no edition, so no canonical URL contradicts the header.
|
||||
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -27,6 +30,7 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
|
||||
self.responses = .init(
|
||||
bundle: bundle,
|
||||
status: .notFound,
|
||||
variesOnAcceptLanguage: true,
|
||||
document: document
|
||||
)
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
|
||||
/// Answers a request whose path carries a trailing slash with `301 Moved Permanently` to the same path without one.
|
||||
///
|
||||
/// The router matches `/page` and `/page/` alike, and `FileMiddleware` serves `/robots.txt/` as readily as `/robots.txt`, so without this
|
||||
/// every address on the site answers under at least two URLs. A search engine treats those as separate pages competing with each other, and any link
|
||||
/// earned by one is not credited to the other. Redirecting collapses them onto the form the pages already name as canonical.
|
||||
///
|
||||
/// Two deliberate choices:
|
||||
/// - **The `Location` is relative.** A relative target resolves against the request's own scheme and host, so the middleware needs no configured
|
||||
/// origin and cannot be aimed elsewhere by a forged `Host` header.
|
||||
/// - **Only `GET` and `HEAD` are redirected.** A client answering a `301` to a `POST` may repeat it as a `GET` and drop the body, so a form
|
||||
/// submission is left to the route that already matches it.
|
||||
///
|
||||
/// - Note: `Context` is the request context the middleware is resolved against.
|
||||
public struct TrailingSlashRedirectMiddleware<Context: RequestContext>: Sendable {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a trailing-slash redirect middleware.
|
||||
public init() {}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension TrailingSlashRedirectMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Redirects a `GET` or `HEAD` whose path carries a trailing slash, and passes every other request down the chain.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the redirect, or the downstream response.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let path = request.uri.path
|
||||
let canonical = canonicalPath(of: path)
|
||||
|
||||
guard
|
||||
request.method == .get || request.method == .head,
|
||||
canonical != path
|
||||
else {
|
||||
return try await next(
|
||||
request,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
var response = Response(status: .movedPermanently)
|
||||
|
||||
response.headers[.location] = canonical + query(of: request)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension TrailingSlashRedirectMiddleware {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The path with its trailing slashes removed, which is the form the pages name as their canonical URL.
|
||||
///
|
||||
/// A path of nothing but slashes collapses to the root, so `//` redirects to `/` while `/` itself is left alone.
|
||||
/// - Parameter path: the requested path.
|
||||
/// - Returns: the canonical form of the path.
|
||||
func canonicalPath(
|
||||
of path: String
|
||||
) -> String {
|
||||
var canonical = path
|
||||
|
||||
while canonical.hasSuffix(.pathSeparator), canonical != .pathSeparator {
|
||||
canonical.removeLast()
|
||||
}
|
||||
|
||||
return canonical
|
||||
}
|
||||
|
||||
/// The query the redirect preserves, so a campaign-tagged link survives the canonicalization.
|
||||
/// - Parameter request: the incoming request.
|
||||
/// - Returns: the query prefixed with its delimiter, or an empty string when the request carries none.
|
||||
func query(
|
||||
of request: Request
|
||||
) -> String {
|
||||
guard
|
||||
let query = request.uri.query,
|
||||
!query.isEmpty
|
||||
else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return .querySeparator + query
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
|
||||
private extension String {
|
||||
/// The separator a canonical path never ends on, and the root path it collapses to.
|
||||
static let pathSeparator = "/"
|
||||
/// The delimiter placed between a redirect target's path and its query.
|
||||
static let querySeparator = "?"
|
||||
}
|
||||
+16
-6
@@ -7,8 +7,12 @@ import Localization
|
||||
/// A per-language collection of pre-rendered HTML responses.
|
||||
///
|
||||
/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring
|
||||
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and
|
||||
/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone.
|
||||
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header naming the
|
||||
/// language it was rendered in.
|
||||
///
|
||||
/// `Vary: Accept-Language` is the caller's to declare, since only the caller knows how it picks the language: a responder that negotiates the
|
||||
/// header sets it, so shared caches key on the language rather than serving one to everyone; a responder that pins the language by route does
|
||||
/// not, since the header would announce a negotiation that never happens.
|
||||
public struct LocalizedHTMLCollectionResponse: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -25,21 +29,27 @@ public struct LocalizedHTMLCollectionResponse: Sendable {
|
||||
/// - Parameters:
|
||||
/// - bundle: the bundle whose String Catalog names the languages the document is rendered for.
|
||||
/// - status: the status applied to every response. Defaults to `.ok`.
|
||||
/// - variesOnAcceptLanguage: whether the responses declare `Vary: Accept-Language`. Defaults to `false`; a responder that
|
||||
/// negotiates the header passes `true`.
|
||||
/// - document: builds the document to render for a given locale.
|
||||
public init<Document: HTMLDocument>(
|
||||
bundle: Bundle,
|
||||
status: HTTPResponse.Status = .ok,
|
||||
variesOnAcceptLanguage: Bool = false,
|
||||
document: (Locale) -> Document
|
||||
) {
|
||||
self.list = .init(bundle: bundle)
|
||||
self.responses = list.all
|
||||
.reduce(into: [:]) { responses, language in
|
||||
var headers: HTTPFields = [.contentLanguage: language]
|
||||
|
||||
if variesOnAcceptLanguage {
|
||||
headers[.vary] = "Accept-Language"
|
||||
}
|
||||
|
||||
responses[language] = CachedHTMLResponse(
|
||||
status: status,
|
||||
additionalHeaders: [
|
||||
.contentLanguage: language,
|
||||
.vary: "Accept-Language",
|
||||
],
|
||||
additionalHeaders: headers,
|
||||
document: document(.init(
|
||||
identifier: language
|
||||
))
|
||||
|
||||
@@ -60,11 +60,10 @@ public struct Analytics: Sendable {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The tracker script's attributes: the website id and reporting domains, then each enabled behavior flag; disabled flags are omitted.
|
||||
/// The tracker script's attributes: the website id, the reporting domains when set, then each enabled behavior flag; disabled flags are omitted.
|
||||
public var attributes: [Attribute] {
|
||||
var attributes: [Attribute] = [
|
||||
.init("data-website-id", value: websiteID),
|
||||
.init("data-domains", value: domains)
|
||||
.init("data-website-id", value: websiteID)
|
||||
]
|
||||
|
||||
if !domains.isEmpty {
|
||||
|
||||
@@ -7,6 +7,9 @@ public struct SocialCard: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The locales of the card's other language editions, in Open Graph's `language_TERRITORY` form; empty to omit their tags.
|
||||
public let alternateLocales: [String]
|
||||
|
||||
/// The card's share image, or `nil` to omit its tags.
|
||||
public let image: Image?
|
||||
|
||||
@@ -43,6 +46,7 @@ public struct SocialCard: Sendable {
|
||||
/// - siteName: the name of the site the card belongs to, or `nil` (the default) to omit its tag.
|
||||
/// - locale: the locale of the card's text, ideally in Open Graph's `language_TERRITORY` form (e.g. `en_US`), or `nil` (the default)
|
||||
/// to omit its tag.
|
||||
/// - alternateLocales: the locales the card's page is also published in, in the same form; empty (the default) to omit their tags.
|
||||
/// - image: the card's share image, or `nil` (the default) to omit its tags.
|
||||
/// - type: the Open Graph type of the object the card describes. Defaults to `website`.
|
||||
/// - style: the layout a Twitter card scraper gives the card. Defaults to ``Style/summaryLargeImage``.
|
||||
@@ -52,10 +56,12 @@ public struct SocialCard: Sendable {
|
||||
url: String? = nil,
|
||||
siteName: String? = nil,
|
||||
locale: String? = nil,
|
||||
alternateLocales: [String] = [],
|
||||
image: Image? = nil,
|
||||
type: String = "website",
|
||||
style: Style = .summaryLargeImage
|
||||
) {
|
||||
self.alternateLocales = alternateLocales
|
||||
self.image = image
|
||||
self.locale = locale
|
||||
self.siteName = siteName
|
||||
@@ -68,8 +74,8 @@ public struct SocialCard: Sendable {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and
|
||||
/// the Twitter card style last. A tag whose fact the card does not carry is left out.
|
||||
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, locale, and alternate locales, then the
|
||||
/// image group, and the Twitter card style last. A tag whose fact the card does not carry is left out.
|
||||
public var tags: [Tag] {
|
||||
let tags: [Tag?] = [
|
||||
Tag(type, name: .type),
|
||||
@@ -78,7 +84,7 @@ public struct SocialCard: Sendable {
|
||||
summary.map { Tag($0, name: .description) },
|
||||
url.map { Tag($0, name: .url) },
|
||||
locale.map { Tag($0, name: .locale) },
|
||||
] + (image?.tags ?? []) + [
|
||||
] + alternateLocales.map { Tag($0, name: .localeAlternate) } + (image?.tags ?? []) + [
|
||||
Tag(style.rawValue, name: .twitter),
|
||||
]
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ extension SocialCard.Tag {
|
||||
case imageWidth = "og:image:width"
|
||||
/// The `og:locale` tag, carrying the locale of the card's text.
|
||||
case locale = "og:locale"
|
||||
/// The `og:locale:alternate` tag, carrying the locale of one other language edition of the card's page; repeated once per edition.
|
||||
case localeAlternate = "og:locale:alternate"
|
||||
/// The `og:site_name` tag, carrying the name of the site the card belongs to.
|
||||
case siteName = "og:site_name"
|
||||
/// The `og:title` tag, carrying the card's title.
|
||||
|
||||
@@ -23,6 +23,20 @@ public struct StructuredData: Equatable, Sendable {
|
||||
self.nodes = nodes
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The `@id` of the `Organization` node the site-wide initializer builds.
|
||||
///
|
||||
/// A page asserting a relationship to it — a `Person`'s `worksFor`, say — references this instead of spelling the fragment again, so the two
|
||||
/// cannot drift.
|
||||
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
|
||||
/// - Returns: the organization node's `@id`.
|
||||
public static func organizationID(
|
||||
forSiteURL url: String
|
||||
) -> String {
|
||||
url + "#organization"
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
|
||||
@@ -46,21 +60,49 @@ public extension StructuredData {
|
||||
/// - Parameters:
|
||||
/// - name: the name of the organization and the site.
|
||||
/// - url: the absolute URL the site is served at.
|
||||
/// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property.
|
||||
/// - description: what the organization does, or `nil` (the default) to omit its property.
|
||||
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
|
||||
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
|
||||
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
|
||||
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
|
||||
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
|
||||
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
|
||||
init(
|
||||
name: String,
|
||||
url: String,
|
||||
alternateName: String? = nil,
|
||||
description: String? = nil,
|
||||
areaServed: String? = nil,
|
||||
email: String? = nil,
|
||||
logo: String? = nil,
|
||||
profiles: [String] = []
|
||||
inLanguage: [String] = [],
|
||||
profiles: [String] = [],
|
||||
founder: String? = nil
|
||||
) {
|
||||
let id = url + "#organization"
|
||||
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)))
|
||||
}
|
||||
@@ -72,6 +114,23 @@ public extension StructuredData {
|
||||
))
|
||||
}
|
||||
|
||||
if let founder {
|
||||
organization.append(.init(.founder, value: .reference(founder)))
|
||||
}
|
||||
|
||||
var website: [Property] = [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
.init(.publisher, value: .reference(id)),
|
||||
]
|
||||
|
||||
if !inLanguage.isEmpty {
|
||||
website.append(.init(
|
||||
.inLanguage,
|
||||
value: .array(inLanguage.map(Value.string))
|
||||
))
|
||||
}
|
||||
|
||||
self.init(nodes: [
|
||||
.init(
|
||||
type: .organization,
|
||||
@@ -80,11 +139,7 @@ public extension StructuredData {
|
||||
),
|
||||
.init(
|
||||
type: .website,
|
||||
properties: [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
.init(.publisher, value: .reference(id)),
|
||||
]
|
||||
properties: website
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
+12
@@ -69,6 +69,18 @@ extension StructuredData.Property {
|
||||
// MARK: - Constants
|
||||
|
||||
public extension StructuredData.Property.Name {
|
||||
/// The name the thing a node describes is also known by.
|
||||
static let alternateName: Self = "alternateName"
|
||||
/// The area an organization serves.
|
||||
static let areaServed: Self = "areaServed"
|
||||
/// What the thing a node describes is or does.
|
||||
static let description: Self = "description"
|
||||
/// The address the thing a node describes is written to.
|
||||
static let email: Self = "email"
|
||||
/// The person who founded an organization.
|
||||
static let founder: Self = "founder"
|
||||
/// The language a creative work — a site, a page — is published in.
|
||||
static let inLanguage: Self = "inLanguage"
|
||||
/// The absolute URL of an organization's logo.
|
||||
static let logo: Self = "logo"
|
||||
/// The name of the thing a node describes.
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
extension StructuredData {
|
||||
/// A value of a ``Property``: a string, 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.
|
||||
///
|
||||
/// Every string a value renders is escaped as a JSON literal with `<` escaped as well, so a value can never close the `script`
|
||||
/// tag embedding the payload it renders into.
|
||||
@@ -8,6 +8,8 @@ extension StructuredData {
|
||||
case array([Value])
|
||||
/// A nested node, e.g. the place a schema.org event is located at.
|
||||
case node(Node)
|
||||
/// A whole number, e.g. a list item's position, rendered unquoted so it reads as a number rather than as text.
|
||||
case number(Int)
|
||||
/// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object.
|
||||
case reference(String)
|
||||
/// A string value.
|
||||
@@ -28,6 +30,8 @@ extension StructuredData.Value {
|
||||
"[\(values.map(\.fragment).joined(separator: .Separator.comma))]"
|
||||
case .node(let node):
|
||||
node.fragment
|
||||
case .number(let number):
|
||||
String(number)
|
||||
case .reference(let id):
|
||||
#"{"@id":\#(Self.literal(id))}"#
|
||||
case .string(let string):
|
||||
|
||||
@@ -32,6 +32,17 @@ struct AssetExtensionTests {
|
||||
#expect(fileExtension.folder == folder)
|
||||
}
|
||||
|
||||
// MARK: CaseIterable tests
|
||||
|
||||
@Test
|
||||
func `covers every case in the parameterised tests`() {
|
||||
// `zip` stops at the shorter sequence, so a case missing from the arrays below is silently untested rather
|
||||
// than failing — this is what catches that.
|
||||
#expect(Self.extensions == AssetExtension.allCases)
|
||||
#expect(Self.contentTypes.count == AssetExtension.allCases.count)
|
||||
#expect(Self.folders.count == AssetExtension.allCases.count)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@@ -42,32 +53,44 @@ private extension AssetExtensionTests {
|
||||
|
||||
static let extensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js,
|
||||
.png,
|
||||
.ico,
|
||||
.jpg,
|
||||
.js,
|
||||
.mp4,
|
||||
.png,
|
||||
.svg,
|
||||
.txt,
|
||||
.webmanifest,
|
||||
.webp,
|
||||
.woff2,
|
||||
.xml
|
||||
]
|
||||
static let contentTypes: [String] = [
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
"image/png",
|
||||
"image/vnd.microsoft.icon",
|
||||
"image/jpeg",
|
||||
"text/javascript",
|
||||
"video/mp4",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"text/plain",
|
||||
"application/manifest+json",
|
||||
"image/webp",
|
||||
"font/woff2",
|
||||
"application/xml"
|
||||
]
|
||||
static let folders: [String?] = [
|
||||
"css",
|
||||
nil,
|
||||
"img",
|
||||
"js",
|
||||
"video",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
"img",
|
||||
"font",
|
||||
nil
|
||||
]
|
||||
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import Elementary
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"Analytics.Event+Tagging extension",
|
||||
.tags(.extension)
|
||||
)
|
||||
struct AnalyticsEventTaggingTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `applies the event name to the tag`() {
|
||||
let markup = a(.href("/")) { "Listen" }
|
||||
.attributes(contentsOf: Analytics.Event(name: "instagram").tagging())
|
||||
|
||||
#expect(markup.render().contains(#"data-umami-event="instagram""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies every event attribute to the tag`() {
|
||||
let event = Analytics.Event(
|
||||
name: "playlist",
|
||||
properties: ["set": "avc-xi"]
|
||||
)
|
||||
|
||||
let markup = button {}
|
||||
.attributes(contentsOf: event.tagging())
|
||||
let rendered = markup.render()
|
||||
|
||||
#expect(rendered.contains(#"data-umami-event="playlist""#))
|
||||
#expect(rendered.contains(#"data-umami-event-set="avc-xi""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns one attribute per event attribute`() {
|
||||
let event = Analytics.Event(
|
||||
name: "playlist",
|
||||
properties: ["set": "avc-xi"]
|
||||
)
|
||||
|
||||
let attributes: [HTMLAttribute<HTMLTag.a>] = event.tagging()
|
||||
|
||||
#expect(attributes.count == event.attributes.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies the event name to an SVG tag`() {
|
||||
// `SVGTag.path` is no `HTMLTrait.Attributes.Global`, so this stops compiling if the method narrows back to HTML tags.
|
||||
let rendered = SVG.path {}
|
||||
.attributes(contentsOf: Analytics.Event(name: "logo").tagging())
|
||||
.render()
|
||||
|
||||
#expect(rendered.contains(#"data-umami-event="logo""#))
|
||||
}
|
||||
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"HTTPSRedirectMiddleware middleware",
|
||||
.tags(.middleware)
|
||||
)
|
||||
struct HTTPSRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `redirects a request forwarded over plain http`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "https://example.com/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `preserves the path and the query of the redirected request`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello?utm_source=test&utm_medium=email",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "https://example.com/hello?utm_source=test&utm_medium=email")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches the forwarded scheme regardless of its casing`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "HTTP"]
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reads the leftmost entry of a proxy chain`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http, https"]
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a request forwarded over https through`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "https"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a request without the forwarded header through`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes every request through when the header is not trusted`() async throws {
|
||||
try await app(
|
||||
configuration: .init(
|
||||
origin: "https://example.com",
|
||||
trustForwardedProto: false
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `leaves the well-known space on plain http`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/.well-known/acme-challenge/token",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes every request through when the origin is not itself https`() async throws {
|
||||
try await app(
|
||||
configuration: .init(
|
||||
origin: "http://example.com",
|
||||
trustForwardedProto: true
|
||||
)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get,
|
||||
headers: [.xForwardedProto: "http"]
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HTTPSRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the HTTPS-redirect middleware ahead of a `/hello`
|
||||
/// route returning a plain body and a `/.well-known/acme-challenge/token` route standing in for
|
||||
/// a certificate authority's challenge file.
|
||||
func app(
|
||||
configuration: HTTPSRedirectMiddleware<BasicRequestContext>.Configuration = .init(
|
||||
origin: "https://example.com",
|
||||
trustForwardedProto: true
|
||||
)
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
HTTPSRedirectMiddleware(configuration: configuration)
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.get(".well-known/acme-challenge/token") { _, _ in
|
||||
"token"
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -25,6 +25,9 @@ struct LocalizationMiddlewareTests {
|
||||
router.get("language") { _, context in
|
||||
context.language
|
||||
}
|
||||
router.get("de/language") { _, context in
|
||||
context.language
|
||||
}
|
||||
|
||||
return router
|
||||
}())
|
||||
@@ -69,4 +72,44 @@ struct LocalizationMiddlewareTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `honours a supported lang query parameter over the header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language?lang=de",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "en"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A language's whole URL prefix answers in its language, so its not-found page does too.
|
||||
@Test
|
||||
func `pins the language a leading path segment names over the header`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/de/language",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "en"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `negotiates the header when the lang query parameter is unsupported`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/language?lang=fr",
|
||||
method: .get,
|
||||
headers: [.acceptLanguage: "de"]
|
||||
) { response in
|
||||
#expect(String(buffer: response.body) == "de")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import Infrastructure
|
||||
|
||||
@Suite(
|
||||
"TrailingSlashRedirectMiddleware middleware",
|
||||
.tags(.middleware)
|
||||
)
|
||||
struct TrailingSlashRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `redirects a path carrying a trailing slash`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `preserves the query of the redirected request`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/?utm_source=test&utm_medium=email",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "/hello?utm_source=test&utm_medium=email")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `collapses a path of nothing but slashes onto the root`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "//",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strips every trailing slash at once`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello///",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes the canonical path through`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `leaves the root path alone`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `redirects a head request as it does a get`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .head
|
||||
) { response in
|
||||
#expect(response.status == .movedPermanently)
|
||||
#expect(response.headers[.location] == "/hello")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passes a post through so its body survives`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello/",
|
||||
method: .post
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.location] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension TrailingSlashRedirectMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the trailing-slash middleware ahead of a `/hello`
|
||||
/// route answering both `GET` and `POST`, and a root route standing in for the landing page.
|
||||
func app() -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
TrailingSlashRedirectMiddleware()
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.post("hello") { _, _ in
|
||||
"Posted!"
|
||||
}
|
||||
|
||||
router.get("/") { _, _ in
|
||||
"Root!"
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,6 +18,7 @@ struct SocialCardTests {
|
||||
url: "https://site.example/",
|
||||
siteName: "A Site",
|
||||
locale: "en",
|
||||
alternateLocales: ["nl_NL", "de_DE"],
|
||||
image: .init(
|
||||
url: "https://site.example/img/card.png",
|
||||
width: 2400,
|
||||
@@ -33,6 +34,8 @@ struct SocialCardTests {
|
||||
.init("A summary.", name: .description),
|
||||
.init("https://site.example/", name: .url),
|
||||
.init("en", name: .locale),
|
||||
.init("nl_NL", name: .localeAlternate),
|
||||
.init("de_DE", name: .localeAlternate),
|
||||
.init("https://site.example/img/card.png", name: .image),
|
||||
.init("2400", name: .imageWidth),
|
||||
.init("1260", name: .imageHeight),
|
||||
|
||||
@@ -17,6 +17,7 @@ struct StructuredDataTests {
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
logo: "https://site.example/logo.png",
|
||||
inLanguage: ["en", "nl"],
|
||||
profiles: [
|
||||
"https://social.example/a-site",
|
||||
"https://videos.example/a-site",
|
||||
@@ -26,10 +27,64 @@ struct StructuredDataTests {
|
||||
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
|
||||
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
|
||||
#""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
|
||||
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
|
||||
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `carries the organization's alternate name, description, area, and address in that order`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
alternateName: "The Site",
|
||||
description: "What the site is.",
|
||||
areaServed: "A City",
|
||||
email: "hello@site.example"
|
||||
)
|
||||
|
||||
#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""#
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `references the organization's founder by identifier`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/",
|
||||
profiles: ["https://social.example/a-site"],
|
||||
founder: "https://site.example/who#person"
|
||||
)
|
||||
|
||||
// Last of the organization's properties, after the profiles.
|
||||
#expect(data.payload.contains(
|
||||
#""sameAs":["https://social.example/a-site"],"founder":{"@id":"https://site.example/who#person"}}"#
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the founder when the data carries none`() {
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: "https://site.example/"
|
||||
)
|
||||
|
||||
#expect(!data.payload.contains("founder"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `derives the organization's identifier from the site URL`() {
|
||||
let url = "https://site.example/"
|
||||
let data = StructuredData(
|
||||
name: "A Site",
|
||||
url: url
|
||||
)
|
||||
|
||||
// Another page references the organization by this, so it must name the node the initializer actually builds.
|
||||
#expect(StructuredData.organizationID(forSiteURL: url) == "https://site.example/#organization")
|
||||
#expect(data.payload.contains(##""@id":"\##(StructuredData.organizationID(forSiteURL: url))""##))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits the properties of the facts minimal data does not carry`() {
|
||||
let data = StructuredData(
|
||||
@@ -120,6 +175,9 @@ struct StructuredDataTests {
|
||||
#expect(StructuredData.Value.array([.string("A"), .string("B")]).fragment == #"["A","B"]"#)
|
||||
#expect(StructuredData.Value.reference("https://site.example/#organization").fragment == #"{"@id":"https://site.example/#organization"}"#)
|
||||
#expect(StructuredData.Value.node(.init(type: "Place", properties: [])).fragment == #"{"@type":"Place"}"#)
|
||||
// Unquoted, or a consumer reads the position of a list item as text and cannot order by it.
|
||||
#expect(StructuredData.Value.number(1).fragment == "1")
|
||||
#expect(StructuredData.Value.number(-3).fragment == "-3")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Localization
|
||||
The server-side localization toolkit the **Loud** services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
|
||||
The server-side localization toolkit the platform's services build on: locale-explicit String Catalog lookups, `Accept-Language` negotiation, and the catalog-derived language list — with no dependencies beyond Foundation.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Lookup | `Localize`, a bundle-bound localizer that resolves a catalog key for an explicit locale |
|
||||
| Negotiation | `Negotiate`, which picks the best supported language from an `Accept-Language` header per RFC 9110 |
|
||||
| Negotiation | `Negotiate`, which picks the best supported language from an explicit request or an `Accept-Language` header per RFC 9110 |
|
||||
| Languages | `LanguageList`, the supported and default languages a bundle's String Catalog defines |
|
||||
| Diagnostics | `CatalogState`, the outcome of reading the catalog (`loaded`, `missing`, or `undecodable`) |
|
||||
|
||||
|
||||
@@ -24,18 +24,32 @@ public struct Negotiate: Sendable {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Picks the best supported language for the given `Accept-Language` header value.
|
||||
/// Picks the best supported language for the given explicit request and `Accept-Language` header value.
|
||||
///
|
||||
/// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`.
|
||||
/// A `requested` language — a deliberate choice, such as a language switcher's query parameter — wins over the header when it matches a
|
||||
/// supported language, on the same terms a header tag matches; an unsupported value is ignored, so it cannot select a language the catalog
|
||||
/// does not serve.
|
||||
///
|
||||
/// The header is parsed into its language ranges, which are ordered by descending `q` weight as RFC 9110 prescribes: an entry without a weight
|
||||
/// counts as 1, entries weighted 0 are "not acceptable" and dropped, and equal weights keep the header order. Each tag is then matched against the
|
||||
/// supported languages in turn — first by an exact match, then by its primary language subtag, so `de-AT` resolves to a supported `de` — while the
|
||||
/// `*` wildcard accepts the default language. When the header is absent or matches nothing, the default language is returned.
|
||||
/// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any.
|
||||
/// - Parameters:
|
||||
/// - requested: an explicitly requested language identifier, if any; it overrides the header when supported.
|
||||
/// - acceptLanguage: the raw `Accept-Language` header value, if any.
|
||||
/// - Returns: the identifier of the supported language to serve.
|
||||
public func callAsFunction(
|
||||
requested: String? = nil,
|
||||
acceptLanguage language: String?
|
||||
) -> String {
|
||||
if
|
||||
let requested,
|
||||
let match = match(requested, in: list.all)
|
||||
{
|
||||
return match
|
||||
}
|
||||
|
||||
guard let language else {
|
||||
return list.default
|
||||
}
|
||||
|
||||
@@ -127,4 +127,44 @@ struct NegotiateTests {
|
||||
#expect(language == "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `honours a supported requested language over the header`() {
|
||||
let language = negotiate(
|
||||
requested: "de",
|
||||
acceptLanguage: "en"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `matches a regional requested language by its primary subtag`() {
|
||||
let language = negotiate(
|
||||
requested: "de-AT",
|
||||
acceptLanguage: "en"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores an unsupported requested language`() {
|
||||
let language = negotiate(
|
||||
requested: "fr",
|
||||
acceptLanguage: "de"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores an empty requested language`() {
|
||||
let language = negotiate(
|
||||
requested: "",
|
||||
acceptLanguage: "de"
|
||||
)
|
||||
|
||||
#expect(language == "de")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Persistence
|
||||
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the **Loud** services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
|
||||
The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based data layer the platform's services build on: runtime selection between a PostgreSQL backend and an ephemeral in-memory SQLite one, single-place migration registration, and a database readiness probe.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
@@ -8,7 +8,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
||||
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
||||
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
||||
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
||||
| Scaffolding (internal) | `ExampleRecord`, `CreateExampleRecord`, and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
|
||||
| Scaffolding | The internal `ExampleRecord` and `CreateExampleRecord`, and the public `Example` snapshot and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model |
|
||||
|
||||
## Design rules
|
||||
- **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping.
|
||||
@@ -39,6 +39,8 @@ Tests/
|
||||
plaintext-only and silent fake PostgreSQL servers, and the suite Tag constants
|
||||
```
|
||||
|
||||
> **Exception:** `Example` and `ExampleRepository` are `public` despite sitting under `Internal/`, so they are API a service can call. Move them to `Public/Models` and `Public/Repositories` when the first real domain model replaces them.
|
||||
|
||||
## Testing
|
||||
The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found:
|
||||
```sh
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Utility
|
||||
The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
|
||||
The general-purpose helpers the platform's services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
# Applies only when this directory is itself the build context. The Dockerfile
|
||||
# builds from the repository root (it copies the sibling Packages/), so today's
|
||||
# builds read the root .dockerignore instead — keep the two in step.
|
||||
|
||||
.build
|
||||
.swiftpm
|
||||
.DS_Store
|
||||
.env.local
|
||||
docker-compose.*
|
||||
Makefile
|
||||
README.md
|
||||
README.md
|
||||
|
||||
# The double star matches at every depth; a bare pattern only covers the context root.
|
||||
**/.DS_Store
|
||||
|
||||
# Local database data directory (bind-mounted by Compose)
|
||||
Tests/DB
|
||||
|
||||
@@ -39,6 +39,10 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||
&& apt-get install -y libjemalloc-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pin git to HTTP/1.1 for the dependency clones. GitHub answers the git-upload-pack POST with a 401 when it is sent
|
||||
# over HTTP/2 by the git 2.43 that Noble ships, which SPM reports as a clone failure asking for a username.
|
||||
RUN git config --global http.version HTTP/1.1
|
||||
|
||||
# Set up a build area
|
||||
WORKDIR /build
|
||||
|
||||
|
||||
+85
-26
@@ -3,8 +3,8 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin
|
||||
|
||||
## Overview
|
||||
The service:
|
||||
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
|
||||
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
|
||||
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)).
|
||||
- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
|
||||
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
|
||||
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
|
||||
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
|
||||
@@ -38,14 +38,16 @@ The persistence backend runs as a `Fluent` service inside the application's Serv
|
||||
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
|
||||
```
|
||||
LogRequestsMiddleware
|
||||
→ SecurityHeadersMiddleware (security headers on every response)
|
||||
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
||||
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
||||
→ LocalizationMiddleware (negotiates the request's language)
|
||||
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
|
||||
→ FileMiddleware (serves Resources/Static)
|
||||
RootController (GET / → landing page)
|
||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||
→ SecurityHeadersMiddleware (security headers on every response)
|
||||
→ HTTPSRedirectMiddleware (301 to site.origin when forwarded over plain HTTP)
|
||||
→ TrailingSlashRedirectMiddleware (301 to the path without a trailing slash)
|
||||
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
||||
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
||||
→ LocalizationMiddleware (negotiates the language: ?lang=, path prefix, then Accept-Language)
|
||||
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
|
||||
→ FileMiddleware (serves Resources/Static)
|
||||
RootController (GET / → landing page; GET /<lang> → its other editions)
|
||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||
```
|
||||
|
||||
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
|
||||
@@ -53,17 +55,41 @@ The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET`
|
||||
### Page metadata
|
||||
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
|
||||
|
||||
Four of those are page-authored, optional, and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
|
||||
Four of those are page-authored and optional. `IndexPage` supplies `canonicalURL`; the other three are **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
|
||||
| Property | Renders as | Notes |
|
||||
| --- | --- | --- |
|
||||
| `summary` | `<meta name="description">` | The page's one-line description. |
|
||||
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. |
|
||||
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. |
|
||||
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:profiles:)` builds the site-wide `Organization` + `WebSite` pair. The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
|
||||
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). |
|
||||
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
|
||||
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
|
||||
|
||||
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.
|
||||
|
||||
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas.
|
||||
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the `ogLocale` a social card would carry, the `preloadedFonts` links, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas. The last group lives in `siteMetadata`, which `metadata` returns unchanged — a page that adds head tags of its own composes `siteMetadata` rather than replacing it.
|
||||
|
||||
`ogLocale` renders Open Graph's `language_TERRITORY` form by looking the page's `lang` up in the `ogLocales` map, which ships one pairing: `en` → `en_US`. A site serving a language in a territory of its own repoints or extends the map (`en_NL`, `nl_NL`, …); a language with no entry stays a bare code, which scrapers also accept. Nothing reads it until a page supplies a `socialCard`.
|
||||
|
||||
`preloadedFonts` is empty until the site ships fonts. Listing one emits `<link rel="preload" as="font" crossorigin>` at `/font/<name>.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens.
|
||||
|
||||
### Language editions
|
||||
The `WebsiteLibrary` String Catalog is the single source of truth for the languages the site serves: add a localization and it appears, with no code change. `Language` (`Sources/Library/Internal/Types`) reads that list and derives each language's URLs from it.
|
||||
|
||||
The catalog's source language is the **default** and owns the site's bare paths; every other language answers under a prefix of its own. The root collapses onto the prefix alone, so a Dutch home is `/nl`, not `/nl/` — the spelling `TrailingSlashRedirectMiddleware` redirects away from anyway:
|
||||
|
||||
| Language | Landing page | A page at `/privacy` |
|
||||
| --- | --- | --- |
|
||||
| `en` (default) | `/` | `/privacy` |
|
||||
| `nl` | `/nl` | `/nl/privacy` |
|
||||
|
||||
`RootController` registers the bare route plus one per non-default language. A prefixed route answers in *its* language for every visitor and every crawler — the path is the language choice, so the negotiated context language is ignored, which is what lets a search engine index it as that edition. The template ships an English-only catalog, so it registers the bare route alone.
|
||||
|
||||
Given a `site.origin`, each page then emits the `hreflang` alternates tying its editions together — one per language plus an `x-default` pointing at the default language's edition, whose bare URL negotiates the language and so is the right landing for everyone unmatched. A single-language site emits none: a set naming one edition tells a search engine nothing it cannot already see. `Page+Defaults`' `languageAlternates(origin:path:languages:)` takes the language set, so a page translated into only some of them narrows it rather than advertising an edition that does not exist.
|
||||
|
||||
The same set belongs in the site-wide structured data: `StructuredData`'s `inLanguage` declares it on the `WebSite` node, so a crawler reads the site's languages from the graph as well as from the alternates. A page's social card says the same to a scraper: its `locale` is the edition the page *is*, and `SocialCard`'s `alternateLocales` names the rest, each mapped through `ogLocales` the way `ogLocale` maps the page's.
|
||||
|
||||
`sitemap.xml` is the one part that does *not* follow the catalog: it is a static file, so a new language needs its editions added by hand — `/nl`, `/nl/<page>`, one `<loc>` each, alongside the default language's. Give each the same spelling the page's own canonical carries (the root is the bare origin, with no trailing slash), or the two disagree about which URL is canonical.
|
||||
|
||||
Visitors switch language two ways, both handled by `LocalizationMiddleware` ahead of the routes: a `?lang=` query parameter (what a language switcher links to) and the leading path segment. Either beats `Accept-Language`; a value naming no supported language is ignored. The path segment matters beyond the routed pages — it is what makes an *unrouted* path under a language's prefix answer its not-found page in that language.
|
||||
|
||||
## Configuration
|
||||
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
|
||||
@@ -88,12 +114,14 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
|
||||
### Static file caching
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. |
|
||||
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for the fingerprinted assets (CSS, JS, MP4, JPEG, WebP) and fonts; also marked `immutable`. The pages reference them through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. Fonts are immutable subset files, preloaded unversioned. |
|
||||
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for the remaining `text/*` assets (e.g. `robots.txt`), which keep unversioned URLs; also marked `must-revalidate`. |
|
||||
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
|
||||
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for the remaining images — the icons (ICO, PNG, SVG), which a browser fetches unversioned whatever the markup says, so they cannot be `immutable`. |
|
||||
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else — including `site.webmanifest` (`application/manifest+json`) and `sitemap.xml` (`application/xml`), neither of which is `text/*`. |
|
||||
|
||||
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`) are resolved before the general `text/*` category.
|
||||
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`, `video/mp4`, `image/jpeg`, `image/webp`) resolve before the general `text/*` and `image/*` categories.
|
||||
|
||||
> **An image the markup references without a `?v=` token must be neither JPEG nor WebP**, or it is served immutable for a year and no deploy can dislodge it.
|
||||
|
||||
### Response compression
|
||||
| Config key | Environment variable | Default | Description |
|
||||
@@ -107,6 +135,31 @@ The groups are matched in order, so the specific media types (`text/css`, `text/
|
||||
| `http.port` | `HTTP_PORT` | _none_ | Port the server listens on. Supplied via the `--http-port` CLI flag (the Docker image passes `8080`). |
|
||||
| `http.serverName` | `HTTP_SERVER_NAME` | `CCNWebsite` | Server name and logger label. |
|
||||
|
||||
### HTTPS redirect
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. |
|
||||
|
||||
Redirecting collapses the `http://` and `https://` copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details:
|
||||
|
||||
- **`301`, not `302`** — a temporary redirect keeps the HTTP URLs indexed. Browsers cache it for a long time, so settle the target first.
|
||||
- **Target built from `site.origin`, not the `Host` header** — a client cannot steer it. An origin that is not itself HTTPS disables the middleware instead of looping.
|
||||
- **`/.well-known/` is exempt** — redirecting the ACME challenge path breaks certificate renewal.
|
||||
|
||||
`docker-compose.yml` enables it for production; `docker-compose.override.yml` pins it off for local development.
|
||||
|
||||
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/about` and `/about/` alike, so
|
||||
every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the form without one (`//` collapses to `/`; `/` is left alone). The
|
||||
`Location` is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected `POST` as a
|
||||
`GET` and drop the body.
|
||||
|
||||
### Site
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `site.origin` | `SITE_ORIGIN` | _(set by bootstrap)_ | The public origin the site is served at (scheme and host, no trailing slash). The HTTPS redirect points at it, and the pages' `rel="canonical"` links and `hreflang` alternates derive from it. |
|
||||
|
||||
Bootstrap writes the canonical URL it prompts for here, leaving it empty for the placeholder. An empty or non-HTTPS origin disables the [HTTPS redirect](#https-redirect), which `https.trustForwardedProto` must enable besides — a `301` is cached for a long time, so it is never issued at a host nobody named. An empty origin also leaves the pages without canonical URLs and language alternates, rather than building either against an empty host.
|
||||
|
||||
### Logging
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -115,17 +168,19 @@ The groups are matched in order, so the specific media types (`text/css`, `text/
|
||||
### Persistence
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). |
|
||||
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). Any other value fails the boot. |
|
||||
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
|
||||
| `database.host` | `DATABASE_HOST` | `localhost` | PostgreSQL host. Ignored for `inMemory`. |
|
||||
| `database.port` | `DATABASE_PORT` | `5432` | PostgreSQL port. Ignored for `inMemory`. |
|
||||
| `database.name` | `DATABASE_NAME` | `ccn` | Database name. Ignored for `inMemory`. |
|
||||
| `database.username` | `DATABASE_USERNAME` | `ccn` | Database username. Ignored for `inMemory`. |
|
||||
| `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. |
|
||||
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. |
|
||||
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Any other value fails the boot. Ignored for `inMemory`. |
|
||||
| `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
|
||||
| `database.pool.timeout` | `DATABASE_POOL_TIMEOUT` | `10` | Seconds a query waits for a pooled connection before failing. Ignored for `inMemory`. |
|
||||
|
||||
> **Unrecognized tokens fail the boot.** Neither `database.driver` nor `database.tls` falls back, because both fallbacks are silent and costly: an unrecognized driver would run on the ephemeral in-memory database and discard every write on restart, and an unrecognized posture would land on `prefer`, which hands the password over in plaintext when the upgrade is stripped. The thrown `ConfigError` names the tokens the key accepts.
|
||||
|
||||
> **Connection budget:** the pool holds `database.pool.maxPerEventLoop` connections *per event loop*, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default `max_connections` of 100. Size this against the server's limit, not against the number alone. On an exhausted pool, a query waits up to `database.pool.timeout` before failing.
|
||||
|
||||
See [Persistence](#persistence-1) below for the workflow.
|
||||
@@ -138,10 +193,12 @@ See [Persistence](#persistence-1) below for the workflow.
|
||||
### Rate limiting
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
|
||||
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
|
||||
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
|
||||
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
|
||||
|
||||
> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one.
|
||||
|
||||
### Analytics
|
||||
The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:
|
||||
|
||||
@@ -169,7 +226,7 @@ The tracker's origin is not a configuration key: it is single-sourced in code so
|
||||
| `security.permissionsPolicy` | `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` |
|
||||
| `security.strictTransportSecurity` | `SECURITY_STRICT_TRANSPORT_SECURITY` | _none (omitted)_ |
|
||||
|
||||
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. `docker-compose.yml` enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy.
|
||||
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. `docker-compose.yml` enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy. [`https.trustForwardedProto`](#https-redirect) carries the same caveat: a cached `301` is as sticky as an HSTS commitment.
|
||||
|
||||
## Running locally
|
||||
Directly with Swift:
|
||||
@@ -236,7 +293,7 @@ make pkg-test # = swift test --disable-xctest --enable-code-coverage --enable-
|
||||
|
||||
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. `make pkg-test` runs the service package's own two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
|
||||
|
||||
`Tests/Website.xctestplan` — the plan the `Site.xcodeproj` scheme runs — adds the vendored packages' suites on top of those two: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. From the command line, each of those is run from its own package directory (`swift test` in `Packages/<Name>`).
|
||||
`Tests/Website.xctestplan` — the plan the `CCN.xcodeproj` scheme runs — adds the vendored packages' suites on top of those two: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. From the command line, each of those is run from its own package directory (`swift test` in `Packages/<Name>`).
|
||||
|
||||
The `Persistence` package also has its own suite, run from `Packages/Persistence`. It uses the in-memory backend by default; the PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at a database, so `swift test` stays runnable without one:
|
||||
```sh
|
||||
@@ -250,7 +307,7 @@ The production image is built in release mode with a statically linked Swift run
|
||||
|
||||
`IMAGE_PLATFORM` is the single source of truth for the deployment architecture: `make img-check` and `make img-release` both build for it, and `docker-compose.yml` runs the pulled image with it. Keep it matched to the deployment host — the three have to agree, or a release builds for one architecture and the production Compose file refuses to run it. Local development builds are separate and follow `BUILD_PLATFORM` (see `docker-compose.override.yml`), since they target your machine rather than the deployment.
|
||||
|
||||
The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (`.dockerignore` excludes `**/Tests` for the same reason).
|
||||
The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (the root `.dockerignore` excludes `**/Tests/DB` for the same reason).
|
||||
|
||||
### Base images
|
||||
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
|
||||
@@ -280,7 +337,9 @@ docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Static assets
|
||||
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from.
|
||||
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root.
|
||||
|
||||
Each extension resolves to its own folder (`css/`, `js/`, `img/`, `font/`, `video/`), which a `StaticFile` overrides with `in:` when it needs one of its own — imagery conventionally sits in a folder per page (`img/index`). `img/` ships empty. Every one of them is a constant of the `StaticFile` structure, which is what the pages derive their URLs from.
|
||||
|
||||
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit:
|
||||
- CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`).
|
||||
@@ -289,7 +348,7 @@ The image build optimizes the files under `Resources/Static` in its `assets` sta
|
||||
|
||||
The PNG and SVG passes walk the tree (`--recursive`), so images added in a subdirectory are optimized without touching the Dockerfile.
|
||||
|
||||
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
|
||||
Files keep their names and paths, so the URLs derived from the `StaticFile` constants are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
|
||||
|
||||
Preview the optimized output locally (Docker only, writes to the git-ignored `.build/minified`):
|
||||
```sh
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://ccn.rock-n-co.de/</loc>
|
||||
<loc>https://ccn.rock-n-co.de</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
@@ -16,7 +16,8 @@ import WebsiteLibrary
|
||||
/// band (so a shared database is never migrated on boot).
|
||||
/// - Parameter reader: the configuration reader the values are read from.
|
||||
/// - Returns: the configured application, ready to run as a service.
|
||||
/// - Throws: an error when the persistence service cannot be built (e.g. its TLS context fails to build).
|
||||
/// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
|
||||
/// (e.g. its TLS context fails to build).
|
||||
func application(
|
||||
reader: ConfigReader
|
||||
) async throws -> some ApplicationProtocol {
|
||||
@@ -34,8 +35,9 @@ func application(
|
||||
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
|
||||
}
|
||||
|
||||
let driver = try reader.driver
|
||||
let persistence = try Service(
|
||||
driver: reader.driver,
|
||||
driver: driver,
|
||||
logger: logger
|
||||
)
|
||||
let fluent = persistence()
|
||||
@@ -52,8 +54,12 @@ func application(
|
||||
analytics: reader.analytics,
|
||||
cacheControl: reader.cacheControl,
|
||||
compressionMinResponseSize: reader.compressionMinResponseSize,
|
||||
httpsRedirect: reader.httpsRedirect,
|
||||
rateLimit: reader.rateLimit,
|
||||
securityHeaders: reader.securityHeaders,
|
||||
// An unset origin leaves the pages without canonical URLs and language alternates, rather than
|
||||
// building both against an empty host.
|
||||
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
|
||||
logLevel: reader.logLevel,
|
||||
probe: Probe(fluent: fluent)
|
||||
),
|
||||
@@ -67,7 +73,7 @@ func application(
|
||||
|
||||
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL backend is
|
||||
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
|
||||
if case .inMemory = reader.driver {
|
||||
if case .inMemory = driver {
|
||||
app.beforeServerStarts {
|
||||
try await fluent.migrate()
|
||||
}
|
||||
@@ -90,7 +96,7 @@ func migration(
|
||||
logLevel: reader.logLevel
|
||||
)
|
||||
let service = try Service(
|
||||
driver: reader.driver,
|
||||
driver: try reader.driver,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
@@ -135,22 +141,28 @@ private func logger(
|
||||
/// Builds the application's router.
|
||||
///
|
||||
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
|
||||
/// vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
|
||||
/// HTTPS-redirect middleware that bounces requests forwarded over plain HTTP to the canonical origin, the trailing-slash redirect middleware that
|
||||
/// collapses each path onto its canonical form, the vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
|
||||
/// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's
|
||||
/// language from its `Accept-Language` header, the not-found middleware that serves the error page, and the static file middleware that serves the
|
||||
/// language from its `Accept-Language` header (honouring the `lang` query override and the language a leading path segment names), the
|
||||
/// not-found middleware that serves the not-found page, and the static file middleware that serves the
|
||||
/// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then adds the `RootController` routes that
|
||||
/// render the landing page, and the `HealthController` routes that serve the health check.
|
||||
/// render the landing page — one per language the String Catalog serves — and the `HealthController` routes that serve the health check.
|
||||
///
|
||||
/// The security-headers middleware sits just inside request logging so it covers every response that reaches a client — the landing page, the compressed
|
||||
/// responses, the rendered error page, and the served static files.
|
||||
/// responses, the rendered not-found page, and the served static files. The HTTPS redirect sits directly beneath it, so a redirect carries the security
|
||||
/// headers but skips the negotiation, compression, and file lookup it would otherwise pay for. The trailing-slash redirect follows it, ahead of the
|
||||
/// routes and `FileMiddleware` that would otherwise answer both spellings of every path.
|
||||
/// - Parameters:
|
||||
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
|
||||
/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
|
||||
/// - analytics: the analytics tracker both pages embed, or `nil` to omit it.
|
||||
/// - cacheControl: the cache-control directives applied to the served static files.
|
||||
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
|
||||
/// - rateLimit: the rate limit applied to the rate-limited routes.
|
||||
/// - httpsRedirect: the origin plain-HTTP requests are redirected to, and whether the forwarded-protocol header is trusted.
|
||||
/// - rateLimit: the rate limit configuration, currently applied to no route.
|
||||
/// - securityHeaders: the security headers applied to every response.
|
||||
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
|
||||
/// - logLevel: the level the request-logging middleware logs at.
|
||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||
/// - Returns: the configured router.
|
||||
@@ -160,8 +172,10 @@ private func router(
|
||||
analytics: Analytics?,
|
||||
cacheControl: CacheControl,
|
||||
compressionMinResponseSize: Int,
|
||||
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
|
||||
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||
siteOrigin: String?,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe
|
||||
) -> Router<AppRequestContext> {
|
||||
@@ -177,6 +191,10 @@ private func router(
|
||||
SecurityHeadersMiddleware(
|
||||
configuration: securityHeaders
|
||||
)
|
||||
HTTPSRedirectMiddleware(
|
||||
configuration: httpsRedirect
|
||||
)
|
||||
TrailingSlashRedirectMiddleware()
|
||||
VaryMiddleware()
|
||||
ResponseCompressionMiddleware(
|
||||
minimumResponseSizeToCompress: compressionMinResponseSize
|
||||
@@ -195,6 +213,7 @@ private func router(
|
||||
router.addController {
|
||||
RootController<AppRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics
|
||||
)
|
||||
HealthController<AppRequestContext>(
|
||||
|
||||
@@ -56,10 +56,15 @@ package extension ConfigReader {
|
||||
/// The `Cache-Control` policy applied to static files, grouped by media type.
|
||||
///
|
||||
/// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
|
||||
/// `cache.maxAge.default` keys. Stylesheets and scripts are referenced through fingerprinted URLs (see `FingerprintAssets`) and
|
||||
/// fonts are immutable subset files, so all three are served long-lived and `immutable` — a deploy busts them by changing the URL, never by
|
||||
/// revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation once stale; images and
|
||||
/// everything else are served public with their max-age alone. The groups match in order, so the specific types precede the `text` category.
|
||||
/// `cache.maxAge.default` keys. Stylesheets, scripts, videos, JPEGs, and WebPs are referenced through fingerprinted URLs (see
|
||||
/// `FingerprintAssets`) and fonts are immutable subset files, so all of them are served long-lived and `immutable` — a deploy busts them by
|
||||
/// changing the URL, never by revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation
|
||||
/// once stale; the remaining images cannot be `immutable` — the group covers the icons, and a browser fetches `/favicon.ico` unversioned
|
||||
/// whatever the markup says — and everything else is served public with its max-age alone. The groups match in order, so the specific types
|
||||
/// precede the `text` and `image` categories.
|
||||
///
|
||||
/// - Important: an image the markup references without a `?v=` token must be in neither JPEG nor WebP, or it is served immutable for a year and
|
||||
/// a deploy cannot dislodge it.
|
||||
var cacheControl: CacheControl {
|
||||
let maxAgeAsset = int(
|
||||
forKey: .Cache.maxAgeAsset,
|
||||
@@ -82,6 +87,9 @@ package extension ConfigReader {
|
||||
(.textCss, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.textJavascript, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.font, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.videoMp4, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.imageJpeg, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.imageWebp, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
|
||||
(.image, [.public, .maxAge(maxAgeImage)]),
|
||||
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
|
||||
@@ -100,51 +108,70 @@ package extension ConfigReader {
|
||||
///
|
||||
/// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`,
|
||||
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`,
|
||||
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any other driver value falls back to the in-memory database.
|
||||
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any token but `inMemory` and `postgres` throws a
|
||||
/// ``ConfigError``: a mistyped driver fails the boot rather than running on the in-memory database and discarding every write on restart.
|
||||
var driver: Driver {
|
||||
switch string(
|
||||
forKey: .Database.driver,
|
||||
default: .Database.driver
|
||||
) {
|
||||
case .Database.driverPostgres:
|
||||
return .postgres(
|
||||
.init(
|
||||
host: string(
|
||||
forKey: .Database.host,
|
||||
default: .Database.host
|
||||
),
|
||||
port: int(
|
||||
forKey: .Database.port,
|
||||
default: .Database.port
|
||||
),
|
||||
name: string(
|
||||
forKey: .Database.name,
|
||||
default: .Database.name
|
||||
),
|
||||
username: string(
|
||||
forKey: .Database.username,
|
||||
default: .Database.username
|
||||
),
|
||||
password: string(
|
||||
forKey: .Database.password,
|
||||
default: ""
|
||||
),
|
||||
tls: tls,
|
||||
maxConnectionsPerEventLoop: int(
|
||||
forKey: .Database.poolMaxPerEventLoop,
|
||||
default: .Database.poolMaxPerEventLoop
|
||||
),
|
||||
poolTimeout: .seconds(int(
|
||||
forKey: .Database.poolTimeout,
|
||||
default: .Database.poolTimeout
|
||||
))
|
||||
get throws {
|
||||
switch string(
|
||||
forKey: .Database.driver,
|
||||
default: .Database.driver
|
||||
) {
|
||||
case .Database.driverInMemory:
|
||||
return .inMemory
|
||||
case .Database.driverPostgres:
|
||||
return .postgres(
|
||||
.init(
|
||||
host: string(
|
||||
forKey: .Database.host,
|
||||
default: .Database.host
|
||||
),
|
||||
port: int(
|
||||
forKey: .Database.port,
|
||||
default: .Database.port
|
||||
),
|
||||
name: string(
|
||||
forKey: .Database.name,
|
||||
default: .Database.name
|
||||
),
|
||||
username: string(
|
||||
forKey: .Database.username,
|
||||
default: .Database.username
|
||||
),
|
||||
password: string(
|
||||
forKey: .Database.password,
|
||||
default: ""
|
||||
),
|
||||
tls: try tls,
|
||||
maxConnectionsPerEventLoop: int(
|
||||
forKey: .Database.poolMaxPerEventLoop,
|
||||
default: .Database.poolMaxPerEventLoop
|
||||
),
|
||||
poolTimeout: .seconds(int(
|
||||
forKey: .Database.poolTimeout,
|
||||
default: .Database.poolTimeout
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
default:
|
||||
return .inMemory
|
||||
case let token:
|
||||
throw ConfigError.unknownDatabaseDriver(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HTTPS redirect middleware configuration, built from the `https.trustForwardedProto` key and ``siteOrigin``.
|
||||
///
|
||||
/// Off by default, so a deployment without a proxy in front never redirects on a header its clients could have written themselves. The redirects
|
||||
/// point at ``siteOrigin`` — the same value the pages build their canonical URLs from, so the two cannot disagree.
|
||||
var httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration {
|
||||
.init(
|
||||
origin: siteOrigin,
|
||||
trustForwardedProto: bool(
|
||||
forKey: .HTTPS.trustForwardedProto,
|
||||
default: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// The minimum log level the application emits at, read from the `log.level` key.
|
||||
///
|
||||
/// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case.
|
||||
@@ -164,7 +191,7 @@ package extension ConfigReader {
|
||||
)
|
||||
}
|
||||
|
||||
/// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys.
|
||||
/// The rate limit built from the `rateLimit.*` keys; the template applies it to no route yet.
|
||||
///
|
||||
/// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When `rateLimit.trustForwardedFor` is set,
|
||||
/// clients are keyed by the first `X-Forwarded-For` entry — enable it only behind a reverse proxy that sets the header, since clients can forge it
|
||||
@@ -226,6 +253,17 @@ package extension ConfigReader {
|
||||
)
|
||||
}
|
||||
|
||||
/// The public origin the site is served at (scheme and host, no trailing slash), read from the `site.origin` key.
|
||||
///
|
||||
/// The redirects and any absolute links derive from it, so a staging deployment can point it at itself — or leave it unset — without the
|
||||
/// production origin leaking into its markup.
|
||||
var siteOrigin: String {
|
||||
string(
|
||||
forKey: .Site.origin,
|
||||
default: .Site.origin
|
||||
)
|
||||
}
|
||||
|
||||
/// The directory the static files are served from, read from the `path.staticFiles` key.
|
||||
var staticFilesPath: String {
|
||||
string(
|
||||
@@ -242,16 +280,45 @@ private extension ConfigReader {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
|
||||
/// other value falls back to `prefer`.
|
||||
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key.
|
||||
///
|
||||
/// Any token but `off`, `prefer`, and `require` throws a ``ConfigError``: a mistyped posture (`required`, say) fails the boot rather than
|
||||
/// falling back to `prefer`, which hands the password over in plaintext when the upgrade is stripped.
|
||||
var tls: TLS {
|
||||
switch string(
|
||||
forKey: .Database.tls,
|
||||
default: .Database.tls
|
||||
) {
|
||||
case .Database.tlsOff: .off
|
||||
case .Database.tlsRequire: .require
|
||||
default: .prefer
|
||||
get throws {
|
||||
switch string(
|
||||
forKey: .Database.tls,
|
||||
default: .Database.tls
|
||||
) {
|
||||
case .Database.tlsOff: .off
|
||||
case .Database.tlsPrefer: .prefer
|
||||
case .Database.tlsRequire: .require
|
||||
case let token: throw ConfigError.unknownDatabaseTLS(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ConfigError
|
||||
|
||||
/// A configuration value the executable refuses to boot with; ``description`` names the tokens the key accepts.
|
||||
package enum ConfigError: Error, Equatable, CustomStringConvertible {
|
||||
|
||||
/// The `database.driver` key holds an unrecognized token.
|
||||
case unknownDatabaseDriver(String)
|
||||
|
||||
/// The `database.tls` key holds an unrecognized token.
|
||||
case unknownDatabaseTLS(String)
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
package var description: String {
|
||||
switch self {
|
||||
case .unknownDatabaseDriver(let token):
|
||||
"Unknown 'database.driver' value '\(token)': use '\(String.Database.driverInMemory)' or '\(String.Database.driverPostgres)'."
|
||||
case .unknownDatabaseTLS(let token):
|
||||
"Unknown 'database.tls' value '\(token)': use '\(String.Database.tlsOff)', '\(String.Database.tlsPrefer)', or '\(String.Database.tlsRequire)'."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/// A rendition an image asset is available at, from the narrowest up to the full-size file.
|
||||
///
|
||||
/// ``StaticFile`` names one file per rendition, and a page's `srcset` offers them all so the browser picks by rendered width.
|
||||
/// The declaration order is the `srcset` order: narrowest first.
|
||||
enum ImageWidth: CaseIterable {
|
||||
/// The small rendition, 480 pixels wide.
|
||||
case small
|
||||
/// The medium rendition, 800 pixels wide.
|
||||
case medium
|
||||
/// The large rendition: the full-size file, 1200 pixels wide.
|
||||
case large
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension ImageWidth {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The rendition's width in pixels: what the file is resampled to, and what its `srcset` descriptor states.
|
||||
var width: Int {
|
||||
switch self {
|
||||
case .small: 480
|
||||
case .medium: 800
|
||||
case .large: 1200
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import Infrastructure
|
||||
|
||||
/// A static file shipped with the website service.
|
||||
///
|
||||
/// Each case identifies a file name stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
|
||||
/// `FileMiddleware` middleware. A name can be available with more than one extension (see ``fileExtensions``), each resolving to its own file.
|
||||
enum StaticFile: Asset, CaseIterable {
|
||||
/// The `apple-touch-icon.png` icon.
|
||||
case appleTouchIcon
|
||||
/// The `favicon.ico` icon.
|
||||
case favicon
|
||||
/// The `icon.svg` icon.
|
||||
case icon
|
||||
/// The `icon-192.png` icon for the web manifest.
|
||||
case icon192
|
||||
/// The `icon-512.png` icon for the web manifest.
|
||||
case icon512
|
||||
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
|
||||
case index
|
||||
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
|
||||
case notFound
|
||||
/// The `robots.txt` crawler directives.
|
||||
case robots
|
||||
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
|
||||
case shared
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
case site
|
||||
/// The `sitemap.xml` crawler sitemap.
|
||||
case sitemap
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The file extensions the file is available with.
|
||||
var fileExtensions: [AssetExtension] {
|
||||
switch self {
|
||||
case .appleTouchIcon,
|
||||
.icon192,
|
||||
.icon512: [.png]
|
||||
case .index,
|
||||
.notFound,
|
||||
.shared: [.css, .js]
|
||||
case .favicon: [.ico]
|
||||
case .icon: [.svg]
|
||||
case .robots: [.txt]
|
||||
case .site: [.webmanifest]
|
||||
case .sitemap: [.xml]
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's name, without extension.
|
||||
var fileName: String {
|
||||
switch self {
|
||||
case .appleTouchIcon: "apple-touch-icon"
|
||||
case .favicon: "favicon"
|
||||
case .icon: "icon"
|
||||
case .icon192: "icon-192"
|
||||
case .icon512: "icon-512"
|
||||
case .index: "index"
|
||||
case .notFound: "not-found"
|
||||
case .robots: "robots"
|
||||
case .shared: "shared"
|
||||
case .site: "site"
|
||||
case .sitemap: "sitemap"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,24 @@ import Localization
|
||||
/// The site-wide defaults shared by every page of the website.
|
||||
extension Page {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The fonts preloaded on every page: one per face the stylesheets actually render.
|
||||
///
|
||||
/// Listed rather than derived from every WOFF2 in ``StaticFile/all``: a subset gated by a `unicode-range` no page reaches would add a
|
||||
/// download that never otherwise happens. Empty until the site ships fonts of its own.
|
||||
static var preloadedFonts: [StaticFile] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// The territories paired with the site's languages, in Open Graph's `language_TERRITORY` form.
|
||||
///
|
||||
/// English ships as `en_US`, Open Graph's conventional default. A site serving a language in a territory of its own repoints or extends the
|
||||
/// map (`en_NL`, `nl_NL`, …); a language with no entry stays a bare code, which scrapers also accept.
|
||||
static var ogLocales: [String: String] {
|
||||
["en": "en_US"]
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The document language, derived from the page's locale and falling back to the default language.
|
||||
@@ -14,9 +32,83 @@ extension Page {
|
||||
?? LanguageList().default
|
||||
}
|
||||
|
||||
/// The icon, manifest, and theme colour metadata shared by every page of the website.
|
||||
@HTMLBuilder
|
||||
/// The locale of the page's social card, in Open Graph's `language_TERRITORY` form where ``ogLocales`` pairs the page's language with a
|
||||
/// territory; a language it does not name stays a bare code, which scrapers also accept.
|
||||
///
|
||||
/// Keyed off ``lang``, so the card's locale and the document's `lang` attribute never describe the document differently. Nothing reads it
|
||||
/// until a page supplies a `socialCard` — like ``preloadedFonts``, it is a hook a generated site fills in.
|
||||
var ogLocale: String {
|
||||
Self.ogLocales[lang] ?? lang
|
||||
}
|
||||
|
||||
/// The site-wide head metadata; a page that adds tags of its own composes ``siteMetadata`` rather than replacing it.
|
||||
var metadata: some HTML {
|
||||
siteMetadata
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `x-default` points at the catalog's default language, whose bare URL negotiates the language and so is the right landing for everyone
|
||||
/// unmatched — it stays the catalog's default even when `languages` names a subset.
|
||||
/// - Parameters:
|
||||
/// - origin: the site's public origin, or `nil` to emit nothing.
|
||||
/// - path: the page's bare (default-language) path.
|
||||
/// - languages: the languages the page is published in; every catalog language by default. A page translated into only some of them
|
||||
/// narrows the set, so it never advertises an edition that does not exist.
|
||||
/// - Returns: one `alternate` link per language, followed by the `x-default` link.
|
||||
@HTMLBuilder
|
||||
func languageAlternates(
|
||||
origin: String?,
|
||||
path: String,
|
||||
languages: [Language] = Language.all
|
||||
) -> some HTML {
|
||||
if let origin, languages.count > 1 {
|
||||
ForEach(languages) { language in
|
||||
link(
|
||||
.rel("alternate"),
|
||||
.custom(
|
||||
name: "hreflang",
|
||||
value: language.identifier
|
||||
),
|
||||
.href(language.url(
|
||||
origin: origin,
|
||||
path: path
|
||||
))
|
||||
)
|
||||
}
|
||||
link(
|
||||
.rel("alternate"),
|
||||
.custom(
|
||||
name: "hreflang",
|
||||
value: "x-default"
|
||||
),
|
||||
.href(Language.default.url(
|
||||
origin: origin,
|
||||
path: path
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// font twice.
|
||||
@HTMLBuilder
|
||||
var siteMetadata: some HTML {
|
||||
for file in Self.preloadedFonts {
|
||||
link(
|
||||
.rel("preload"),
|
||||
.href(file.urlPath(for: .woff2)),
|
||||
.as(.font),
|
||||
.custom(
|
||||
name: "type",
|
||||
value: AssetExtension.woff2.contentType
|
||||
),
|
||||
.crossorigin(.anonymous)
|
||||
)
|
||||
}
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href(StaticFile.favicon.urlPath(
|
||||
|
||||
@@ -17,6 +17,9 @@ struct IndexPage {
|
||||
/// The locale the page content is localized to.
|
||||
let locale: Locale
|
||||
|
||||
/// The public origin the page derives its canonical URL and language alternates from, or `nil` to omit them.
|
||||
let siteOrigin: String?
|
||||
|
||||
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||
private let localize: Localize
|
||||
|
||||
@@ -26,15 +29,18 @@ struct IndexPage {
|
||||
/// - Parameters:
|
||||
/// - locale: the locale the page content is localized to.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
locale: Locale,
|
||||
assetVersion: String? = nil,
|
||||
siteOrigin: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = locale
|
||||
self.siteOrigin = siteOrigin
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
|
||||
@@ -44,6 +50,36 @@ struct IndexPage {
|
||||
|
||||
extension IndexPage: Page {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The path the page is served at; ``RootController`` registers its route against it, and the page builds its canonical URL from it.
|
||||
static let path = "/"
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The canonical URL of the page's edition in its language, or `nil` when the origin is unknown.
|
||||
///
|
||||
/// The origin alone for the default language, since canonical URLs carry no trailing slash — `TrailingSlashRedirectMiddleware` enforces
|
||||
/// that on every path but the root, which has nothing to strip.
|
||||
var canonicalURL: String? {
|
||||
siteOrigin.map {
|
||||
Language(of: locale).url(
|
||||
origin: $0,
|
||||
path: Self.path
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The site-wide metadata, preceded by the `hreflang` alternates tying the page's language editions together.
|
||||
@HTMLBuilder
|
||||
var metadata: some HTML {
|
||||
languageAlternates(
|
||||
origin: siteOrigin,
|
||||
path: Self.path
|
||||
)
|
||||
siteMetadata
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
|
||||
@@ -25,8 +25,7 @@ struct NotFoundPage {
|
||||
/// Creates a not-found page localized to the given locale.
|
||||
/// - Parameters:
|
||||
/// - locale: the locale the page content is localized to.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
|
||||
/// default) to leave them unversioned.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
locale: Locale,
|
||||
@@ -38,9 +37,10 @@ struct NotFoundPage {
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Page
|
||||
// MARK: - Page
|
||||
|
||||
extension NotFoundPage: Page {
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Localization
|
||||
|
||||
/// A language the website serves, as its String Catalog names it.
|
||||
///
|
||||
/// The catalog stays the single source of truth: a locale appears in ``all`` once it has a localization, with no code change. The default
|
||||
/// language owns the site's bare paths; every other language answers under a prefix of its own, so each edition has a stable URL a search
|
||||
/// engine can index and the pages' `hreflang` alternates have somewhere to point.
|
||||
struct Language: Hashable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The language's identifier, as the String Catalog names it (e.g. `en`, `nl`).
|
||||
let identifier: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the language with the given identifier, whether or not the catalog serves it.
|
||||
///
|
||||
/// Unvalidated so the URL rules can be exercised — and a language pinned — without the catalog shipping that localization first.
|
||||
/// - Parameter identifier: the language's identifier.
|
||||
init(identifier: String) {
|
||||
self.identifier = identifier
|
||||
}
|
||||
|
||||
/// Creates the language a locale reads as, falling back to ``default`` for anything the catalog does not serve.
|
||||
/// - Parameter locale: the locale a page is localized to.
|
||||
init(of locale: Locale) {
|
||||
guard
|
||||
let identifier = locale.language.languageCode?.identifier,
|
||||
Self.all.contains(Language(identifier: identifier))
|
||||
else {
|
||||
self = .default
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
self.identifier = identifier
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Whether the language owns the site's unprefixed paths.
|
||||
var isDefault: Bool {
|
||||
self == .default
|
||||
}
|
||||
|
||||
/// The prefix of the language's URLs: empty for the default language, which owns the bare paths.
|
||||
var pathPrefix: String {
|
||||
isDefault ? "" : "/\(identifier)"
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The language's URL path for a page's bare path; the root collapses onto the prefix alone, so a Dutch home is `/nl`, not `/nl/`.
|
||||
/// - Parameter barePath: the page's unprefixed path, as ``RootController`` registers it for the default language.
|
||||
/// - Returns: the path this language's edition of the page is served at.
|
||||
func path(_ barePath: String) -> String {
|
||||
guard barePath == "/" else {
|
||||
return pathPrefix + barePath
|
||||
}
|
||||
|
||||
return pathPrefix.isEmpty ? barePath : pathPrefix
|
||||
}
|
||||
|
||||
/// The absolute URL of the page's edition in this language; the default language's root stays the bare origin, keeping canonicals slashless.
|
||||
/// - Parameters:
|
||||
/// - origin: the site's public origin, without a trailing slash.
|
||||
/// - barePath: the page's unprefixed path.
|
||||
/// - Returns: the absolute URL this language's edition of the page is served at.
|
||||
func url(
|
||||
origin: String,
|
||||
path barePath: String
|
||||
) -> String {
|
||||
let path = path(barePath)
|
||||
|
||||
return path == "/" ? origin : origin + path
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Catalog
|
||||
|
||||
extension Language {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Every language the module's String Catalog provides a localization for, in the catalog list's stable order.
|
||||
static var all: [Language] {
|
||||
LanguageList()
|
||||
.all
|
||||
.map(Language.init(identifier:))
|
||||
}
|
||||
|
||||
/// The language served when no supported language matches a request: the catalog's source language.
|
||||
static var `default`: Language {
|
||||
.init(identifier: LanguageList().default)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Infrastructure
|
||||
|
||||
/// A static file shipped with the website service.
|
||||
///
|
||||
/// Each constant declares one file stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
|
||||
/// `FileMiddleware` middleware. A file can be available with more than one extension (see ``fileExtensions``), each resolving to its own file, and
|
||||
/// sits in its extension's own folder unless it names a ``folder`` of its own.
|
||||
struct StaticFile: Asset {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The file extensions the file is available with.
|
||||
let fileExtensions: [AssetExtension]
|
||||
|
||||
/// The file's name, without extension.
|
||||
let fileName: String
|
||||
|
||||
/// The folder holding the file, or `nil` when it sits in its extensions' own folders.
|
||||
///
|
||||
/// Imagery sits in a folder per page — `img/index`, `img/about` — rather than in its extension's own.
|
||||
let folder: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Declares a static file.
|
||||
/// - Parameters:
|
||||
/// - fileName: the file's name, without extension.
|
||||
/// - folder: the folder holding the file, or `nil` (the default) to use each extension's own folder.
|
||||
/// - fileExtensions: the extensions the file is available with, one file each.
|
||||
init(
|
||||
_ fileName: String,
|
||||
in folder: String? = nil,
|
||||
as fileExtensions: AssetExtension...
|
||||
) {
|
||||
self.fileExtensions = fileExtensions
|
||||
self.fileName = fileName
|
||||
self.folder = folder
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// Every file the service ships.
|
||||
///
|
||||
/// Spelled out because Swift cannot enumerate a type's own constants: a file missing here is still served, but goes untested.
|
||||
static let all: [Self] = [
|
||||
.appleTouchIcon,
|
||||
.favicon,
|
||||
.icon,
|
||||
.icon192,
|
||||
.icon512,
|
||||
.index,
|
||||
.notFound,
|
||||
.robots,
|
||||
.shared,
|
||||
.site,
|
||||
.sitemap,
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
/// The `srcset` value offering every rendition of a responsive image, narrowest first, ending on the full-size file.
|
||||
///
|
||||
/// The renditions are named by the given closure, which conventionally gives the `large` one the bare file name and the narrower ones their
|
||||
/// width — as `srcset` files are usually named:
|
||||
///
|
||||
/// ```swift
|
||||
/// static func portrait(_ width: ImageWidth) -> Self {
|
||||
/// Self(width == .large ? "portrait" : "portrait-\(width.width)", in: "img/about", as: .jpg, .webp)
|
||||
/// }
|
||||
/// ```
|
||||
/// - Parameters:
|
||||
/// - fileExtension: the format the renditions are named in.
|
||||
/// - version: the version token appended to each URL, or `nil` (the default) to leave them unversioned.
|
||||
/// - rendition: the file naming a given width.
|
||||
/// - Returns: the `srcset` value for that format.
|
||||
static func srcSet(
|
||||
for fileExtension: AssetExtension,
|
||||
version: String? = nil,
|
||||
rendition: (ImageWidth) -> Self
|
||||
) -> String {
|
||||
ImageWidth.allCases
|
||||
.map { "\(rendition($0).urlPath(for: fileExtension, version: version)) \($0.width)w" }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
extension StaticFile {
|
||||
/// The `apple-touch-icon.png` icon.
|
||||
static let appleTouchIcon = Self("apple-touch-icon", as: .png)
|
||||
|
||||
/// The `favicon.ico` icon.
|
||||
static let favicon = Self("favicon", as: .ico)
|
||||
|
||||
/// The `icon.svg` icon.
|
||||
static let icon = Self("icon", as: .svg)
|
||||
|
||||
/// The `icon-192.png` icon for the web manifest.
|
||||
static let icon192 = Self("icon-192", as: .png)
|
||||
|
||||
/// The `icon-512.png` icon for the web manifest.
|
||||
static let icon512 = Self("icon-512", as: .png)
|
||||
|
||||
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
|
||||
static let index = Self("index", as: .css, .js)
|
||||
|
||||
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
|
||||
static let notFound = Self("not-found", as: .css, .js)
|
||||
|
||||
/// The `robots.txt` crawler directives.
|
||||
static let robots = Self("robots", as: .txt)
|
||||
|
||||
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
|
||||
static let shared = Self("shared", as: .css, .js)
|
||||
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
static let site = Self("site", as: .webmanifest)
|
||||
|
||||
/// The `sitemap.xml` crawler sitemap.
|
||||
static let sitemap = Self("sitemap", as: .xml)
|
||||
}
|
||||
@@ -116,6 +116,9 @@ private extension HealthController {
|
||||
}
|
||||
|
||||
/// Builds a JSON response carrying the given status and payload.
|
||||
///
|
||||
/// Every response is marked `noindex`: the checks answer `200 OK` to anyone, and `robots.txt` allows the whole site. A `Disallow` rule would
|
||||
/// stop the crawl but not the indexing, and would publish the paths to everyone reading the file.
|
||||
/// - Parameters:
|
||||
/// - status: the HTTP status of the response.
|
||||
/// - payload: the JSON body of the response.
|
||||
@@ -126,7 +129,10 @@ private extension HealthController {
|
||||
) -> Response {
|
||||
Response(
|
||||
status: status,
|
||||
headers: [.contentType: "application/json"],
|
||||
headers: [
|
||||
.contentType: "application/json",
|
||||
.robotsTag: "noindex",
|
||||
],
|
||||
body: .init(byteBuffer: .init(string: payload))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,15 +25,18 @@ public struct RootController<Context: LocalizedRequestContext> {
|
||||
/// Creates a root controller.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
assetVersion: String? = nil,
|
||||
siteOrigin: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.responses = .init(bundle: .module) {
|
||||
IndexPage(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
@@ -55,6 +58,15 @@ extension RootController: RouterController {
|
||||
use: index
|
||||
)
|
||||
|
||||
// Every non-default language answers under a prefix of its own, so the `hreflang` alternates the page advertises
|
||||
// resolve and a crawler can index each edition at a stable URL. A single-language catalog adds none.
|
||||
for language in Language.all where !language.isDefault {
|
||||
routes.get(
|
||||
.init(language.path(IndexPage.path)),
|
||||
use: index(in: language)
|
||||
)
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
@@ -84,6 +96,23 @@ private extension RootController {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the handler serving the landing page in one fixed language, for the routes carrying the language in their path.
|
||||
///
|
||||
/// The path *is* the language choice, so the negotiated context language is ignored: a prefixed URL answers in its language for every
|
||||
/// visitor and every crawler alike, which is what lets a search engine index it as that edition.
|
||||
/// - Parameter language: the language the route serves.
|
||||
/// - Returns: the handler answering requests for that edition of the page.
|
||||
func index(
|
||||
in language: Language
|
||||
) -> @Sendable (Request, Context) -> Response {
|
||||
{ request, _ in
|
||||
responses.response(
|
||||
for: language.identifier,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
@@ -91,7 +120,7 @@ private extension RootController {
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``RootController`` route paths.
|
||||
enum Root {
|
||||
/// The path of the landing page.
|
||||
static let index: RouterPath = "/"
|
||||
/// The path of the landing page; the page builds its canonical URL from the same constant.
|
||||
static let index: RouterPath = .init(IndexPage.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ extension AbsoluteConfigKey {
|
||||
/// The absolute configuration key for the server's name.
|
||||
public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName)
|
||||
}
|
||||
/// A namespace for the HTTPS redirect configuration keys, as absolute keys.
|
||||
public enum HTTPS {
|
||||
/// The absolute configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header.
|
||||
public static let trustForwardedProto: AbsoluteConfigKey = .init(.HTTPS.trustForwardedProto)
|
||||
}
|
||||
/// A namespace for the logging configuration keys, as absolute keys.
|
||||
public enum Log {
|
||||
/// The absolute configuration key for the minimum log level.
|
||||
|
||||
@@ -58,6 +58,12 @@ extension ConfigKey {
|
||||
/// The configuration key for the server's name.
|
||||
public static let serverName: ConfigKey = "http.serverName"
|
||||
}
|
||||
/// A namespace for the HTTPS redirect configuration keys.
|
||||
public enum HTTPS {
|
||||
/// The configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header, redirecting the plain-HTTP
|
||||
/// ones to the site origin (enable only behind a trusted proxy that sets the header).
|
||||
public static let trustForwardedProto: ConfigKey = "https.trustForwardedProto"
|
||||
}
|
||||
/// A namespace for the logging configuration keys.
|
||||
public enum Log {
|
||||
/// The configuration key for the minimum log level.
|
||||
@@ -92,4 +98,9 @@ extension ConfigKey {
|
||||
/// The configuration key for the `Strict-Transport-Security` header value (omitted when unset).
|
||||
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
|
||||
}
|
||||
/// A namespace for the site configuration keys.
|
||||
public enum Site {
|
||||
/// The configuration key for the public origin the site is served at (scheme and host, no trailing slash).
|
||||
public static let origin: ConfigKey = "site.origin"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ public extension NotFoundMiddleware {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
|
||||
/// Creates a not-found middleware that renders the website's not-found page, localized to the module's String Catalog languages.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the error page embeds, or `nil` (the default) to omit it.
|
||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit the tracker script.
|
||||
init(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
|
||||
@@ -24,7 +24,9 @@ extension String {
|
||||
/// A namespace for the persistence's default configuration values and recognized tokens.
|
||||
public enum Database {
|
||||
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
|
||||
public static let driver = "inMemory"
|
||||
public static let driver = driverInMemory
|
||||
/// The driver token selecting the in-memory SQLite backend.
|
||||
public static let driverInMemory = "inMemory"
|
||||
/// The driver token selecting the PostgreSQL backend.
|
||||
public static let driverPostgres = "postgres"
|
||||
/// The default PostgreSQL host.
|
||||
@@ -34,9 +36,11 @@ extension String {
|
||||
/// The default database username.
|
||||
public static let username = "ccn"
|
||||
/// The default TLS posture token.
|
||||
public static let tls = "prefer"
|
||||
public static let tls = tlsPrefer
|
||||
/// The TLS token disabling TLS.
|
||||
public static let tlsOff = "off"
|
||||
/// The TLS token upgrading to TLS only when the server offers it.
|
||||
public static let tlsPrefer = "prefer"
|
||||
/// The TLS token requiring TLS.
|
||||
public static let tlsRequire = "require"
|
||||
}
|
||||
@@ -50,4 +54,13 @@ extension String {
|
||||
/// The website server's name.
|
||||
public static let name = "CCNWebsite"
|
||||
}
|
||||
/// A namespace for the site string constants.
|
||||
public enum Site {
|
||||
/// The default public origin the site is served at (scheme and host, no trailing slash).
|
||||
///
|
||||
/// Bootstrap writes the canonical URL it prompts for here, leaving it empty for the placeholder. An empty or non-HTTPS origin disables
|
||||
/// the HTTPS redirect, which `https.trustForwardedProto` must enable besides — a `301` is cached for a long time, so it is never issued
|
||||
/// at a host nobody named.
|
||||
public static let origin = "https://ccn.rock-n-co.de"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ struct AppTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
// Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable.
|
||||
// Referenced through fingerprinted URLs, or an immutable subset file in the case of a font, so all of these are served immutable.
|
||||
private let immutableExtensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js
|
||||
.jpg,
|
||||
.js,
|
||||
.mp4,
|
||||
.webp,
|
||||
.woff2
|
||||
]
|
||||
|
||||
// Absolute path to the copy of the package's "Resources/Static" folder made into the test bundle
|
||||
@@ -107,7 +111,7 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: StaticFile.allCases)
|
||||
@Test(arguments: StaticFile.all)
|
||||
func `static files to be served`(
|
||||
staticFile file: StaticFile
|
||||
) async throws {
|
||||
|
||||
@@ -61,6 +61,96 @@ struct ConfigReaderPropertiesTests {
|
||||
#expect(reader(values: [.Analytics.websiteID: ""]).analytics == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"shared.css",
|
||||
"shared.js",
|
||||
"milker-400.woff2",
|
||||
"booth.mp4",
|
||||
"portrait.jpg",
|
||||
"portrait.webp"
|
||||
])
|
||||
func `cache control to mark the fingerprinted media immutable`(
|
||||
file: String
|
||||
) throws {
|
||||
let header = try #require(reader().cacheControl.getCacheControlHeader(for: file))
|
||||
|
||||
#expect(header.contains("immutable"))
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
// The icons are exempt: a browser fetches `/favicon.ico` unversioned whatever the markup says, and the manifest names the PNGs.
|
||||
"favicon.ico",
|
||||
"icon-192.png",
|
||||
"icon.svg"
|
||||
])
|
||||
func `cache control to leave the unversioned images mutable`(
|
||||
file: String
|
||||
) throws {
|
||||
let header = try #require(reader().cacheControl.getCacheControlHeader(for: file))
|
||||
|
||||
#expect(!header.contains("immutable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cache control to have the unversioned text revalidate`() throws {
|
||||
let header = try #require(reader().cacheControl.getCacheControlHeader(for: "robots.txt"))
|
||||
|
||||
#expect(header.contains("must-revalidate"))
|
||||
#expect(!header.contains("immutable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to default to the in-memory database`() throws {
|
||||
guard case .inMemory = try reader().driver else {
|
||||
Issue.record("Expected the in-memory driver when 'database.driver' is unset.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to select postgres when configured`() throws {
|
||||
guard case .postgres = try reader(values: [.Database.driver: "postgres"]).driver else {
|
||||
Issue.record("Expected the postgres driver when 'database.driver' selects it.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to throw on an unknown token`() {
|
||||
// A silent fallback would run the deployment on the ephemeral database and discard every write on restart.
|
||||
#expect(throws: ConfigError.unknownDatabaseDriver("mariadb")) {
|
||||
_ = try reader(values: [.Database.driver: "mariadb"]).driver
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
String.Database.tlsOff,
|
||||
String.Database.tlsPrefer,
|
||||
String.Database.tlsRequire
|
||||
])
|
||||
func `driver to accept a recognized tls token`(token: String) throws {
|
||||
let reader = reader(values: [
|
||||
.Database.driver: .init(stringLiteral: .Database.driverPostgres),
|
||||
.Database.tls: .init(stringLiteral: token)
|
||||
])
|
||||
|
||||
guard case .postgres = try reader.driver else {
|
||||
Issue.record("Expected the postgres driver for the '\(token)' TLS token.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to throw on an unknown tls token`() {
|
||||
// A silent fallback to `prefer` hands the password over in plaintext when the upgrade is stripped.
|
||||
#expect(throws: ConfigError.unknownDatabaseTLS("required")) {
|
||||
_ = try reader(values: [
|
||||
.Database.driver: .init(stringLiteral: .Database.driverPostgres),
|
||||
.Database.tls: "required"
|
||||
]).driver
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"ImageWidth enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct ImageWidthTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
ImageWidth.allCases,
|
||||
[480, 800, 1200]
|
||||
))
|
||||
func `width`(
|
||||
for imageWidth: ImageWidth,
|
||||
expects width: Int
|
||||
) {
|
||||
#expect(imageWidth.width == width)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"StaticFile enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct StaticFileTests {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias File = StaticFile
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileExtensions
|
||||
))
|
||||
func `file extensions`(
|
||||
for file: File,
|
||||
expects extensions: [AssetExtension]
|
||||
) {
|
||||
#expect(file.fileExtensions == extensions)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileNames
|
||||
))
|
||||
func `file name`(
|
||||
for file: File,
|
||||
expects fileName: String
|
||||
) {
|
||||
#expect(file.fileName == fileName)
|
||||
}
|
||||
|
||||
// MARK: CaseIterable tests
|
||||
|
||||
@Test
|
||||
func `all cases`() {
|
||||
#expect(File.allCases.count == 11)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFileTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let fileExtensions: [[AssetExtension]] = [
|
||||
[.png],
|
||||
[.ico],
|
||||
[.svg],
|
||||
[.png],
|
||||
[.png],
|
||||
[.css, .js],
|
||||
[.css, .js],
|
||||
[.txt],
|
||||
[.css, .js],
|
||||
[.webmanifest],
|
||||
[.xml]
|
||||
]
|
||||
static let fileNames: [String] = [
|
||||
"apple-touch-icon",
|
||||
"favicon",
|
||||
"icon",
|
||||
"icon-192",
|
||||
"icon-512",
|
||||
"index",
|
||||
"not-found",
|
||||
"robots",
|
||||
"shared",
|
||||
"site",
|
||||
"sitemap"
|
||||
]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Page+Defaults extension",
|
||||
.tags(.extensionTests)
|
||||
)
|
||||
struct PageDefaultsTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test
|
||||
func `pairs english with a territory for its social card locale`() {
|
||||
#expect(StubPage().ogLocale == "en_US")
|
||||
}
|
||||
|
||||
/// Open Graph prefers `language_TERRITORY`, but a scraper accepts the bare code — better than pinning a territory the site never named.
|
||||
@Test
|
||||
func `leaves an unpaired language as a bare code`() {
|
||||
#expect(StubPage(locale: .init(identifier: "nl")).ogLocale == "nl")
|
||||
}
|
||||
|
||||
// MARK: Method tests
|
||||
|
||||
@Test
|
||||
func `renders no language alternates without an origin`() {
|
||||
let html = StubPage().languageAlternates(
|
||||
origin: nil,
|
||||
path: "/",
|
||||
languages: Self.languages
|
||||
).render()
|
||||
|
||||
#expect(html.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no language alternates for a single language`() {
|
||||
let html = StubPage().languageAlternates(
|
||||
origin: "https://example.com",
|
||||
path: "/",
|
||||
languages: [.default]
|
||||
).render()
|
||||
|
||||
#expect(html.isEmpty)
|
||||
}
|
||||
|
||||
/// The default language keeps the bare root; the prefixed edition collapses onto its prefix rather than carrying a trailing slash.
|
||||
@Test
|
||||
func `renders an alternate per language and an x-default at the root`() {
|
||||
let html = StubPage().languageAlternates(
|
||||
origin: "https://example.com",
|
||||
path: "/",
|
||||
languages: Self.languages
|
||||
).render()
|
||||
|
||||
#expect(html.contains(#"<link rel="alternate" hreflang="en" href="https://example.com">"#))
|
||||
#expect(html.contains(#"<link rel="alternate" hreflang="nl" href="https://example.com/nl">"#))
|
||||
#expect(html.contains(#"<link rel="alternate" hreflang="x-default" href="https://example.com">"#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `prefixes the alternates of a nested page`() {
|
||||
let html = StubPage().languageAlternates(
|
||||
origin: "https://example.com",
|
||||
path: "/privacy",
|
||||
languages: Self.languages
|
||||
).render()
|
||||
|
||||
#expect(html.contains(#"<link rel="alternate" hreflang="en" href="https://example.com/privacy">"#))
|
||||
#expect(html.contains(#"<link rel="alternate" hreflang="nl" href="https://example.com/nl/privacy">"#))
|
||||
#expect(html.contains(#"<link rel="alternate" hreflang="x-default" href="https://example.com/privacy">"#))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PageDefaultsTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// A two-language set, standing in for the multi-language catalog the template itself does not ship.
|
||||
static let languages: [Language] = [
|
||||
.default,
|
||||
.init(identifier: "nl")
|
||||
]
|
||||
|
||||
// MARK: Types
|
||||
|
||||
/// The barest ``Page`` conformance, so the shared defaults can be exercised without a real page's content.
|
||||
struct StubPage: Page {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let assetVersion: String? = nil
|
||||
let locale: Locale
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
init(locale: Locale = .init(identifier: "en")) {
|
||||
self.locale = locale
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
var content: some HTML {
|
||||
HTMLRaw("")
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"Stub"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -32,6 +32,39 @@ struct IndexPageTests {
|
||||
#expect(html.contains("/js/index.js"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no font preloads until fonts are declared`() {
|
||||
// The template ships no fonts, so a preload link here would point at a file the service does not serve.
|
||||
#expect(IndexPage.preloadedFonts.isEmpty)
|
||||
#expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="preload""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no canonical URL until an origin is given`() {
|
||||
#expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="canonical""#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders the canonical URL as the bare origin for the default language`() {
|
||||
// The root is the one path with no trailing slash for `TrailingSlashRedirectMiddleware` to strip.
|
||||
let html = IndexPage(
|
||||
locale: .init(identifier: "en"),
|
||||
siteOrigin: "https://example.com"
|
||||
).render()
|
||||
|
||||
#expect(html.contains(#"<link rel="canonical" href="https://example.com">"#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no language alternates for a single-language catalog`() {
|
||||
// The template serves one language, so a set naming one edition would tell a crawler nothing.
|
||||
#expect(Language.all.count == 1)
|
||||
#expect(!IndexPage(
|
||||
locale: .init(identifier: "en"),
|
||||
siteOrigin: "https://example.com"
|
||||
).render().contains("hreflang"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders versioned asset URLs when given a version`() {
|
||||
let html = IndexPage(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"Language type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct LanguageTests {
|
||||
|
||||
// MARK: Catalog tests
|
||||
|
||||
/// The template ships an English-only catalog, so a site adding a language sees this pin fail and reviews the URL rules below.
|
||||
@Test
|
||||
func `derives its languages from the String Catalog`() {
|
||||
#expect(Language.all == [Language(identifier: "en")])
|
||||
#expect(Language.default == Language(identifier: "en"))
|
||||
}
|
||||
|
||||
// MARK: Initializer tests
|
||||
|
||||
@Test
|
||||
func `reads the language a locale names`() {
|
||||
#expect(Language(of: .init(identifier: "en")).identifier == "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reads a regional locale as its primary language`() {
|
||||
#expect(Language(of: .init(identifier: "en_GB")).identifier == "en")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `falls back to the default for a language the catalog does not serve`() {
|
||||
#expect(Language(of: .init(identifier: "fr")) == .default)
|
||||
}
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test
|
||||
func `owns the bare paths as the default language`() {
|
||||
let language = Language.default
|
||||
|
||||
#expect(language.isDefault)
|
||||
#expect(language.pathPrefix.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `prefixes its paths as a non-default language`() {
|
||||
let language = Language(identifier: "nl")
|
||||
|
||||
#expect(!language.isDefault)
|
||||
#expect(language.pathPrefix == "/nl")
|
||||
}
|
||||
|
||||
// MARK: Method tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
["/", "/privacy"],
|
||||
["/", "/privacy"]
|
||||
))
|
||||
func `path`(
|
||||
forDefaultLanguageAt barePath: String,
|
||||
expects path: String
|
||||
) {
|
||||
#expect(Language.default.path(barePath) == path)
|
||||
}
|
||||
|
||||
/// The root collapses onto the prefix alone: a trailing slash is the very spelling `TrailingSlashRedirectMiddleware` redirects away from.
|
||||
@Test(arguments: zip(
|
||||
["/", "/privacy"],
|
||||
["/nl", "/nl/privacy"]
|
||||
))
|
||||
func `path`(
|
||||
forPrefixedLanguageAt barePath: String,
|
||||
expects path: String
|
||||
) {
|
||||
#expect(Language(identifier: "nl").path(barePath) == path)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
["/", "/privacy"],
|
||||
["https://example.com", "https://example.com/privacy"]
|
||||
))
|
||||
func `url`(
|
||||
forDefaultLanguageAt barePath: String,
|
||||
expects url: String
|
||||
) {
|
||||
#expect(Language.default.url(
|
||||
origin: "https://example.com",
|
||||
path: barePath
|
||||
) == url)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
["/", "/privacy"],
|
||||
["https://example.com/nl", "https://example.com/nl/privacy"]
|
||||
))
|
||||
func `url`(
|
||||
forPrefixedLanguageAt barePath: String,
|
||||
expects url: String
|
||||
) {
|
||||
#expect(Language(identifier: "nl").url(
|
||||
origin: "https://example.com",
|
||||
path: barePath
|
||||
) == url)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"StaticFile type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct StaticFileTests {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias File = StaticFile
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
/// The path is asserted rather than the three facts behind it: it is the only form the rest of the service sees.
|
||||
@Test(arguments: Self.paths)
|
||||
func `relative paths`(
|
||||
for file: File,
|
||||
expects paths: [String]
|
||||
) {
|
||||
#expect(file.fileExtensions.map(file.relativePath) == paths)
|
||||
}
|
||||
|
||||
/// A preload URL must match the stylesheet's `@font-face` source exactly, so it carries the extension's folder and no version query.
|
||||
@Test
|
||||
func `unversioned font paths`() {
|
||||
let font = File("a-font-400", as: .woff2)
|
||||
|
||||
#expect(font.relativePath(for: .woff2) == "font/a-font-400.woff2")
|
||||
#expect(font.urlPath(for: .woff2) == "/font/a-font-400.woff2")
|
||||
}
|
||||
|
||||
/// Imagery sits in a folder per page, so a declared folder replaces the extension's own for every extension the file has.
|
||||
@Test
|
||||
func `declared folder paths`() {
|
||||
let portrait = File("portrait", in: "img/about", as: .jpg, .webp)
|
||||
|
||||
#expect(portrait.relativePath(for: .jpg) == "img/about/portrait.jpg")
|
||||
#expect(portrait.relativePath(for: .webp) == "img/about/portrait.webp")
|
||||
}
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
[AssetExtension.jpg, .webp],
|
||||
["jpg", "webp"]
|
||||
))
|
||||
func `srcset offers every rendition narrowest first`(
|
||||
for fileExtension: AssetExtension,
|
||||
expects suffix: String
|
||||
) {
|
||||
let srcSet = File.srcSet(for: fileExtension) { width in
|
||||
File(
|
||||
width == .large ? "portrait" : "portrait-\(width.width)",
|
||||
in: "img/about",
|
||||
as: .jpg, .webp
|
||||
)
|
||||
}
|
||||
|
||||
#expect(srcSet == [
|
||||
"/img/about/portrait-480.\(suffix) 480w",
|
||||
"/img/about/portrait-800.\(suffix) 800w",
|
||||
"/img/about/portrait.\(suffix) 1200w"
|
||||
].joined(separator: ", "))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `srcset versions every rendition when given a version`() {
|
||||
let srcSet = File.srcSet(for: .jpg, version: "0123456789abcdef") { _ in
|
||||
File("portrait", in: "img/about", as: .jpg)
|
||||
}
|
||||
|
||||
#expect(srcSet.components(separatedBy: "?v=0123456789abcdef").count - 1 == ImageWidth.allCases.count)
|
||||
}
|
||||
|
||||
// MARK: Constants tests
|
||||
|
||||
@Test
|
||||
func `all files`() {
|
||||
#expect(File.all.count == Self.paths.count)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFileTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// Every file paired with the path it is served at, one per extension, in ``StaticFile/all`` order.
|
||||
static let paths: [(File, [String])] = [
|
||||
(.appleTouchIcon, ["apple-touch-icon.png"]),
|
||||
(.favicon, ["favicon.ico"]),
|
||||
(.icon, ["icon.svg"]),
|
||||
(.icon192, ["icon-192.png"]),
|
||||
(.icon512, ["icon-512.png"]),
|
||||
(.index, ["css/index.css", "js/index.js"]),
|
||||
(.notFound, ["css/not-found.css", "js/not-found.js"]),
|
||||
(.robots, ["robots.txt"]),
|
||||
(.shared, ["css/shared.css", "js/shared.js"]),
|
||||
(.site, ["site.webmanifest"]),
|
||||
(.sitemap, ["sitemap.xml"]),
|
||||
]
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
@@ -29,6 +30,7 @@ struct HealthControllerTests {
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ok"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +56,7 @@ struct HealthControllerTests {
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ready"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -99,6 +102,7 @@ struct HealthControllerTests {
|
||||
#expect(response.status == .serviceUnavailable)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"unavailable"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -131,6 +131,37 @@ struct RootControllerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serves the landing page with a canonical URL when an origin is configured`() async throws {
|
||||
try await app(
|
||||
siteOrigin: "https://example.com"
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(String(buffer: response.body).contains(#"<link rel="canonical" href="https://example.com">"#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The template's catalog serves one language, so the default owns every path and no prefixed route is registered.
|
||||
@Test
|
||||
func `registers no prefixed route for a single-language catalog`() async throws {
|
||||
let prefixed = Language.all.filter { !$0.isDefault }
|
||||
|
||||
#expect(prefixed.isEmpty)
|
||||
|
||||
try await app.test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/en",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `embeds no analytics tracker by default`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
@@ -176,13 +207,15 @@ private extension RootControllerTests {
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose root controller appends the given version token to the landing
|
||||
/// page's asset URLs and embeds the given analytics tracker.
|
||||
/// page's asset URLs, derives its absolute links from the given origin, and embeds the given analytics tracker.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs.
|
||||
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
|
||||
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||
/// - Returns: the configured application.
|
||||
func app(
|
||||
assetVersion: String? = nil,
|
||||
siteOrigin: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
@@ -193,6 +226,7 @@ private extension RootControllerTests {
|
||||
|
||||
router.addRoutes(RootController<WebsiteRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics
|
||||
).routes)
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ extension Tag {
|
||||
@Tag static var controller: Tag
|
||||
/// Tests exercising an enumeration of the Website library.
|
||||
@Tag static var enumeration: Tag
|
||||
/// Tests exercising an extension of the Website library.
|
||||
@Tag static var extensionTests: Tag
|
||||
/// Tests exercising a page of the Website library.
|
||||
@Tag static var page: Tag
|
||||
/// Tests exercising a type of the Website library.
|
||||
@Tag static var type: Tag
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ services:
|
||||
dockerfile: Services/Website/Dockerfile
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
HTTPS_TRUST_FORWARDED_PROTO: "false"
|
||||
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_TLS: ${DATABASE_TLS:-off}
|
||||
|
||||
@@ -19,12 +19,11 @@ services:
|
||||
environment:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-CCNWebsite}
|
||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
||||
# Falls back to the policy the app ships with; set it in `.env` to allow the analytics origin,
|
||||
# which must match `String.Analytics.origin`.
|
||||
SECURITY_CONTENT_SECURITY_POLICY: "${SECURITY_CONTENT_SECURITY_POLICY:-default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'}"
|
||||
# Persistence: a managed PostgreSQL database. Provide the password via the environment or a secret — never
|
||||
# commit it.
|
||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
||||
HTTPS_TRUST_FORWARDED_PROTO: "${HTTPS_TRUST_FORWARDED_PROTO:-true}"
|
||||
DATABASE_DRIVER: ${DATABASE_DRIVER:-postgres}
|
||||
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
|
||||
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||
|
||||
Reference in New Issue
Block a user