Initial commit.

This commit is contained in:
2026-08-19 23:19:08 +02:00
commit 22e737d9c2
153 changed files with 11680 additions and 0 deletions
@@ -0,0 +1,46 @@
import Hummingbird
/// A result builder that collects the route collections of ``RouterController`` values into a stack.
///
/// Mirrors the `MiddlewareFixedTypeBuilder` Hummingbird uses for `addMiddleware`, letting controllers be listed declaratively rather than having
/// their routes added one statement at a time.
@resultBuilder
public enum RouteCollectionBuilder<Context: RequestContext> {
public static func buildExpression(
_ controller: some RouterController<Context>
) -> [RouteCollection<Context>] {
[controller.routes]
}
public static func buildBlock(
_ collections: [RouteCollection<Context>]...
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
public static func buildOptional(
_ collections: [RouteCollection<Context>]?
) -> [RouteCollection<Context>] {
collections ?? []
}
public static func buildEither(
first collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildEither(
second collections: [RouteCollection<Context>]
) -> [RouteCollection<Context>] {
collections
}
public static func buildArray(
_ collections: [[RouteCollection<Context>]]
) -> [RouteCollection<Context>] {
collections.flatMap { $0 }
}
}
@@ -0,0 +1,52 @@
/// 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 {
/// 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 Scalable Vector Graphics image.
case svg
/// A plain text file.
case txt
/// A web application manifest file.
case webmanifest
/// An Extensible Markup Language file.
case xml
}
// MARK: - Extensions
public extension AssetExtension {
// MARK: Computed
/// The file's content type.
var contentType: String {
switch self {
case .css: "text/css"
case .js: "text/javascript"
case .png: "image/png"
case .ico: "image/vnd.microsoft.icon"
case .svg: "image/svg+xml"
case .txt: "text/plain"
case .webmanifest: "application/manifest+json"
case .xml: "application/xml"
}
}
/// The folder within the static root that holds files with this extension, if any.
var folder: String? {
switch self {
case .css: "css"
case .js: "js"
default: nil
}
}
}
@@ -0,0 +1,12 @@
import HTTPTypes
public extension HTTPField.Name {
/// The `Permissions-Policy` field name (not provided as a standard `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-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")!
}
@@ -0,0 +1,9 @@
extension Int {
/// A namespace for the rate limit's default configuration values.
public enum RateLimit {
/// The default number of requests admitted per client per window.
public static let limit = 5
/// The default window length, in seconds (1 minute).
public static let window = 60
}
}
@@ -0,0 +1,32 @@
import Hummingbird
public extension RouterMethods {
// MARK: Methods
/// Adds the routes of ``RouterController`` values to the router using the ``RouteCollectionBuilder`` result builder.
///
/// Mirrors `addMiddleware`, letting controllers be listed declaratively:
///
/// ```swift
/// router.addController {
/// RootController<AppRequestContext>()
/// HealthController<AppRequestContext>()
/// }
/// ```
///
/// Each controller's route collection is added at the router's root, exactly as a sequence of `addRoutes(_:)` calls would.
/// - Parameter build: the controller stack result builder.
/// - Returns: the router, so calls can be chained.
@discardableResult
func addController(
@RouteCollectionBuilder<Context> _ build: () -> [RouteCollection<Context>]
) -> Self {
for collection in build() {
addRoutes(collection)
}
return self
}
}
@@ -0,0 +1,22 @@
extension String {
/// A namespace for the security headers' default configuration values.
///
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is "sticky" in browsers, so it stays off unless explicitly
/// configured in production.
public enum Security {
/// The default `Content-Security-Policy`.
///
/// Restricts every resource to the site's own origin (`default-src 'self'`), blocks plugins (`object-src 'none'`), pins the document
/// base URL (`base-uri 'self'`), and forbids framing (`frame-ancestors 'none'`). No inline-style exception is included, so pages must
/// link external stylesheets.
public static let contentSecurityPolicy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/// The default `X-Content-Type-Options` (disables MIME sniffing).
public static let contentTypeOptions = "nosniff"
/// The default `X-Frame-Options` (forbids framing the page).
public static let frameOptions = "DENY"
/// The default `Referrer-Policy`.
public static let referrerPolicy = "strict-origin-when-cross-origin"
/// The default `Permissions-Policy` (denies access to powerful browser features a static site does not use).
public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
}
}
@@ -0,0 +1,83 @@
import Foundation
import Logging
/// Derives a version token from the contents of the static files directory.
///
/// The token folds every file under the directory its relative path and its bytes, in a stable order into one FNV-1a digest, so it changes whenever any
/// asset changes and agrees across the instances of a deployment. The pages append it to their asset URLs (`?v=<token>`), which lets the assets be
/// served with a long-lived, immutable cache policy: a deploy that changes an asset changes the URLs pointing at it, so no client ever revalidates or holds a
/// stale copy.
public struct FingerprintAssets: Sendable {
// MARK: Properties
/// The logger unreadable files are reported to, or `nil` to skip them silently.
private let logger: Logger?
// MARK: Initializers
/// Creates an asset fingerprinting method.
/// - Parameter logger: the logger unreadable files are reported to, or `nil` (the default) to skip them silently.
public init(
logger: Logger? = nil
) {
self.logger = logger
}
// MARK: Functions
/// Fingerprints the static files under the given directory.
///
/// A file that cannot be read is reported to the ``logger`` and left out of the token, so its later changes would not bust caches a warning there
/// usually points at a permissions problem in the deployment.
/// - Parameter path: the directory the static files are served from.
/// - Returns: the version token, or `nil` when the directory holds no readable files (asset URLs are then left unversioned).
public func callAsFunction(
_ path: String
) -> String? {
let manager = FileManager.default
guard let enumerated = manager.enumerator(atPath: path) else {
return nil
}
// The path-based enumerator yields paths relative to the directory, so the token depends only on the
// directory's contents never on where the directory itself lives (the URL-based enumerator standardizes
// symlinked bases, e.g. `/var/` to `/private/var/`, which would leak the absolute path into the hash).
var files: [String] = []
while let relativePath = enumerated.nextObject() as? String {
if enumerated.fileAttributes?[.type] as? FileAttributeType == .typeRegular {
files.append(relativePath)
}
}
var hash = FNV1aHash()
var hashed = false
for relativePath in files.sorted() {
guard let contents = manager.contents(
atPath: "\(path)/\(relativePath)"
) else {
logger?.warning(
"Static file could not be read while fingerprinting; the version token will not reflect it.",
metadata: ["path": "\(relativePath)"]
)
continue
}
hash.combine(Array(relativePath.utf8))
hash.combine(contents)
hashed = true
}
guard hashed else {
return nil
}
return hash.digest
}
}
@@ -0,0 +1,59 @@
import Foundation
import HTTPTypes
import Hummingbird
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``.
///
/// 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.
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
/// Negotiates the request's language from its `Accept-Language` header.
private let negotiate: Negotiate
// MARK: Initializers
/// Creates a localization middleware that negotiates against the given bundle's String Catalog languages.
/// - Parameter bundle: the bundle whose String Catalog names the supported languages.
public init(
bundle: Bundle
) {
self.negotiate = .init(bundle: bundle)
}
}
// MARK: - RouterMiddleware
extension LocalizationMiddleware: RouterMiddleware {
// MARK: Functions
/// Negotiates the request's language and records it on the context before passing it down.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var context = context
context.language = negotiate(
acceptLanguage: request.headers[.acceptLanguage]
)
return try await next(request, context)
}
}
@@ -0,0 +1,74 @@
import Elementary
import Foundation
import Hummingbird
/// Serves a custom error page for requests that match neither a route nor a static file.
///
/// 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.
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
/// The error page, rendered once per supported language and reused for every not-found response.
private let responses: LocalizedHTMLCollectionResponse
// MARK: Initializers
/// Creates a not-found middleware.
/// - Parameters:
/// - bundle: the bundle whose String Catalog names the languages the page is rendered for.
/// - document: builds the error page to render for a given locale.
public init<Document: HTMLDocument>(
bundle: Bundle,
document: (Locale) -> Document
) {
self.responses = .init(
bundle: bundle,
status: .notFound,
document: document
)
}
}
// MARK: - RouterMiddleware
extension NotFoundMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain, rendering the error page if it results in a not-found response.
///
/// Any error other than `.notFound` is rethrown unchanged.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response, or the rendered error page with a `404 Not Found` status.
/// - Throws: any non-not-found error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
do {
return try await next(request, context)
}
catch let error {
guard
let responseError = error as? any HTTPResponseError,
responseError.status == .notFound
else {
throw error
}
return responses.response(
for: context.language,
request: request
)
}
}
}
@@ -0,0 +1,291 @@
import Foundation
import HTTPTypes
import Hummingbird
import NIOCore
import Synchronization
/// Rejects a client's requests with `429 Too Many Requests` once they exceed a fixed-window rate limit.
///
/// Added to the routes that must not be hammered the subscription endpoint, an unauthenticated database write it admits up to the configured limit of
/// requests per client per window, and answers the excess with `429 Too Many Requests` and a `Retry-After` header naming the seconds until the
/// window resets.
///
/// A client is keyed by the first `X-Forwarded-For` entry when the ``Configuration`` trusts it, by the connection's remote address otherwise, and by
/// one shared bucket when neither names the client. The counters live in memory with a bounded capacity, so a flood of distinct clients cannot grow the
/// store without bound and each instance of a multi-instance deployment enforces its own budget.
public struct RateLimitMiddleware<Context: RequestContext>: Sendable {
// MARK: Properties
/// The fixed-window request counters, keyed by client.
private let buckets: Buckets
/// The limits the middleware enforces.
private let configuration: Configuration
// MARK: Initializers
/// Creates a rate-limit middleware.
/// - Parameter configuration: the limits the middleware enforces. Defaults to a budget suited to a form endpoint: a handful of requests per
/// client per minute.
public init(
configuration: Configuration = .init()
) {
self.buckets = .init(
limit: configuration.limit,
window: configuration.window
)
self.configuration = configuration
}
}
// MARK: - RouterMiddleware
extension RateLimitMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain while the client stays within its budget, and answers it with `429 Too Many Requests` and a `Retry-After`
/// header once it does not.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response, or the `429` rejection.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
let admission = buckets.admit(
client(
for: request,
context: context
)
)
switch admission {
case .admitted:
return try await next(
request,
context
)
case .limited(let retryAfter):
var response = Response(status: .tooManyRequests)
response.headers[.retryAfter] = String(max(1, retryAfter.components.seconds))
return response
}
}
}
// MARK: - Helpers
private extension RateLimitMiddleware {
// MARK: Methods
/// The key identifying the requesting client: the first `X-Forwarded-For` entry when trusted, the connection's remote address otherwise, and one
/// bucket shared by every unidentifiable client when neither is known.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - Returns: the client key the request is counted under.
func client(
for request: Request,
context: Context
) -> String {
if
configuration.trustForwardedFor,
let forwarded = request.headers[.xForwardedFor]?
.split(separator: ",")
.first?
.trimmingCharacters(in: .whitespaces),
!forwarded.isEmpty
{
return forwarded
}
if let address = (context as? any RemoteAddressRequestContext)?.remoteAddress {
return address.ipAddress
?? address.description
}
return .unidentified
}
}
// MARK: - Buckets
private extension RateLimitMiddleware {
/// The outcome of asking the ``Buckets`` store to admit a request.
enum Admission {
/// The request is within the client's budget.
case admitted
/// The client exhausted its budget; the payload is the time until its window resets.
case limited(retryAfter: Duration)
}
/// The fixed-window request counters, keyed by client.
///
/// The counters sit behind a mutex rather than an actor: an admission is a handful of dictionary operations, so the lock is held only briefly and the
/// calling task never suspends requests skip the executor hop an actor would add on every pass through the middleware.
final class Buckets: Sendable {
// MARK: Properties
/// The maximum number of clients tracked at once, bounding the store's memory.
private let capacity: Int
/// The per-client counters: the start of the client's current window and its request count.
private let counters: Mutex<[String: (start: ContinuousClock.Instant, count: Int)]>
/// The number of requests admitted per client per ``window``.
private let limit: Int
/// The length of the fixed window the ``limit`` applies to.
private let window: Duration
// MARK: Initializers
/// Creates a counter store.
/// - Parameters:
/// - limit: the number of requests admitted per client per window.
/// - window: the length of the fixed window the limit applies to.
/// - capacity: the maximum number of clients tracked at once.
init(
limit: Int,
window: Duration,
capacity: Int = 10_000
) {
self.capacity = capacity
self.counters = .init([:])
self.limit = limit
self.window = window
}
// MARK: Functions
/// Counts a request against the client's current window and admits it while the count stays within the limit.
/// - Parameter client: the key the request is counted under.
/// - Returns: the admission outcome.
func admit(
_ client: String
) -> Admission {
let now = ContinuousClock.now
return counters.withLock { counters in
if let counter = counters[client], now < counter.start.advanced(by: window) {
guard counter.count < limit else {
return .limited(retryAfter: now.duration(to: counter.start.advanced(by: window)))
}
counters[client] = (counter.start, counter.count + 1)
return .admitted
}
makeRoom(
in: &counters,
at: now
)
counters[client] = (now, 1)
return .admitted
}
}
// MARK: Methods
/// Keeps the store within its capacity before a new client is tracked: expired windows are dropped first, and when the store remains full, the
/// oldest live windows are evicted in one batch a tenth of the capacity so the sort that finds them runs once per batch of admissions
/// instead of once per request while a flood of distinct clients keeps the store full.
/// - Parameters:
/// - counters: the counters the room is made in.
/// - now: the instant the expiry is evaluated against.
private func makeRoom(
in counters: inout [String: (start: ContinuousClock.Instant, count: Int)],
at now: ContinuousClock.Instant
) {
guard counters.count >= capacity else {
return
}
counters = counters.filter {
now < $0.value.start.advanced(by: window)
}
let headroom = max(1, capacity / 10)
let excess = counters.count - (capacity - headroom)
guard excess > 0 else {
return
}
let oldest = counters
.sorted { $0.value.start < $1.value.start }
.prefix(excess)
for counter in oldest {
counters.removeValue(forKey: counter.key)
}
}
}
}
// MARK: - Configuration
extension RateLimitMiddleware {
/// The limits a ``RateLimitMiddleware`` enforces.
public struct Configuration: Sendable {
// MARK: Properties
/// The number of requests admitted per client per ``window``.
public let limit: Int
/// Whether a client is keyed by the first `X-Forwarded-For` entry.
///
/// Enable it only behind a reverse proxy that sets the header there, the connection's own address would name the proxy for every visitor,
/// sharing one budget across all of them. On a directly reachable server the header is client-supplied, so trusting it lets a client forge fresh keys
/// at will.
public let trustForwardedFor: Bool
/// The length of the fixed window the ``limit`` applies to.
public let window: Duration
// MARK: Initializers
/// Creates a rate-limit configuration.
/// - Parameters:
/// - limit: the number of requests admitted per client per window.
/// - window: the length of the fixed window the limit applies to.
/// - trustForwardedFor: whether a client is keyed by the first `X-Forwarded-For` entry.
public init(
limit: Int = .RateLimit.limit,
window: Duration = .seconds(Int.RateLimit.window),
trustForwardedFor: Bool = false
) {
self.limit = limit
self.trustForwardedFor = trustForwardedFor
self.window = window
}
}
}
// MARK: - String+Constants
private extension String {
/// The bucket shared by every client the middleware cannot identify.
static let unidentified = "unidentified"
}
@@ -0,0 +1,154 @@
import HTTPTypes
import Hummingbird
/// Stamps a set of security-related HTTP headers onto every response.
///
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever response bubbles back up the rendered pages, the
/// error page produced by ``NotFoundMiddleware``, and every static file served by `FileMiddleware` so the browser applies the strict, hardened
/// interpretation of the content instead of its lenient legacy defaults.
///
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for every request, so the per-request cost is a handful of
/// header copies.
public struct SecurityHeadersMiddleware<Context: RequestContext> {
// MARK: Properties
/// The precomputed headers applied to every response.
private let fields: HTTPFields
// MARK: Initializers
/// Creates a security-headers middleware.
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened baseline suitable for a static site, with
/// `Strict-Transport-Security` left off (see ``Configuration``).
public init(
configuration: Configuration = .init()
) {
self.fields = configuration.fields
}
}
// MARK: - RouterMiddleware
extension SecurityHeadersMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and stamps the configured security headers onto the response on the way back up.
///
/// Errors that can render themselves (`HTTPResponseError`, like the `HTTPError`s thrown by the controllers) are converted to their response
/// here rather than left to the router: the router converts them above the middleware chain, where the response would escape these headers.
/// Existing values for the same header names are replaced so downstream middleware cannot leave a weaker policy in place.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the security headers applied.
/// - Throws: any downstream error that does not render as an HTTP response.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var response: Response
do {
response = try await next(
request,
context
)
} catch let error as any HTTPResponseError {
response = try error.response(
from: request,
context: context
)
}
for field in fields {
response.headers[field.name] = field.value
}
return response
}
}
// MARK: - Helpers
private extension SecurityHeadersMiddleware.Configuration {
// MARK: Computed
/// The configuration expressed as the headers to apply, omitting any whose value is `nil`.
var fields: HTTPFields {
var fields = HTTPFields()
fields[.contentSecurityPolicy] = contentSecurityPolicy
fields[.xContentTypeOptions] = contentTypeOptions
fields[.frameOptions] = frameOptions
fields[.referrerPolicy] = referrerPolicy
fields[.permissionsPolicy] = permissionsPolicy
fields[.strictTransportSecurity] = strictTransportSecurity
return fields
}
}
// MARK: - Configuration
extension SecurityHeadersMiddleware {
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
///
/// Each property maps to a single response header. A `nil` value omits that header entirely, which is how `Strict-Transport-Security` stays
/// disabled by default: it is only safe to send over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be switched on
/// (via configuration) only in TLS-terminated production.
public struct Configuration: Sendable {
// MARK: Properties
/// The `Content-Security-Policy` value (controls which sources the browser will load).
public let contentSecurityPolicy: String?
/// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`).
public let contentTypeOptions: String?
/// The `X-Frame-Options` value (controls whether the page may be framed).
public let frameOptions: String?
/// The `Referrer-Policy` value (controls how much referrer information is shared).
public let referrerPolicy: String?
/// The `Permissions-Policy` value (gates access to powerful browser features).
public let permissionsPolicy: String?
/// The `Strict-Transport-Security` value, or `nil` to omit the header (the default).
public let strictTransportSecurity: String?
// MARK: Initializers
/// Creates a security-headers configuration.
///
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except `strictTransportSecurity`, which defaults
/// to `nil` (omitted). Pass `nil` for any header to drop it from the response.
/// - Parameters:
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
/// - contentTypeOptions: the `X-Content-Type-Options` value.
/// - frameOptions: the `X-Frame-Options` value.
/// - referrerPolicy: the `Referrer-Policy` value.
/// - permissionsPolicy: the `Permissions-Policy` value.
/// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it.
public init(
contentSecurityPolicy: String? = String.Security.contentSecurityPolicy,
contentTypeOptions: String? = String.Security.contentTypeOptions,
frameOptions: String? = String.Security.frameOptions,
referrerPolicy: String? = String.Security.referrerPolicy,
permissionsPolicy: String? = String.Security.permissionsPolicy,
strictTransportSecurity: String? = nil
) {
self.contentSecurityPolicy = contentSecurityPolicy
self.contentTypeOptions = contentTypeOptions
self.frameOptions = frameOptions
self.referrerPolicy = referrerPolicy
self.permissionsPolicy = permissionsPolicy
self.strictTransportSecurity = strictTransportSecurity
}
}
}
@@ -0,0 +1,71 @@
import Foundation
import HTTPTypes
import Hummingbird
/// Appends header names to the `Vary` header of every response passing through.
///
/// Placed just above the response-compression middleware, it marks each response as varying on `Accept-Encoding`: the static files and pre-rendered
/// pages are served with `Cache-Control: public`, so without the signal a shared cache could store a compressed body and hand it to a client that
/// never advertised support for the encoding.
///
/// Names already present on a response's `Vary` header such as the `Accept-Language` the localized pages carry are kept, and duplicates are not
/// added.
public struct VaryMiddleware<Context: RequestContext>: Sendable {
// MARK: Properties
/// The header names appended to every response's `Vary` header.
private let names: [String]
// MARK: Initializers
/// Creates a vary middleware.
/// - Parameter fields: the header names appended to every response's `Vary` header. Defaults to `Accept-Encoding`, the request header
/// the response-compression middleware acts on.
public init(
fields: [HTTPField.Name] = [.acceptEncoding]
) {
self.names = fields.map(\.rawName)
}
}
// MARK: - RouterMiddleware
extension VaryMiddleware: RouterMiddleware {
// MARK: Functions
/// Passes the request down the chain and appends the configured names to the response's `Vary` header on the way back up.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the downstream response with the `Vary` names applied.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
var response = try await next(
request,
context
)
var vary = response.headers[.vary]?
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) } ?? []
for name in names where !vary.contains(where: {
$0.caseInsensitiveCompare(name) == .orderedSame
}) {
vary.append(name)
}
response.headers[.vary] = vary.joined(separator: ", ")
return response
}
}
@@ -0,0 +1,87 @@
/// An asset shipped with a website: a file stored under the static files root and served by Hummingbird's `FileMiddleware` middleware.
///
/// A conforming asset supplies its file name and the extensions it is available with, each resolving to its own file; the protocol derives the paths from them:
/// the file's path within the static files root and the URL path it is served at, optionally versioned to bust caches. Each file lands in its extension's own
/// folder unless the asset names a ``folder`` of its own.
public protocol Asset: Sendable {
// MARK: Properties
/// The folder within the static files root that holds the asset's files, or `nil` (the default) to use each extension's own folder.
var folder: String? { get }
/// The file extensions the asset is available with.
var fileExtensions: [AssetExtension] { get }
/// The asset's file name, without extension.
var fileName: String { get }
}
// MARK: - Implementations
public extension Asset {
// MARK: Computed
/// The asset's files live in each extension's own folder by default.
var folder: String? { nil }
// MARK: Methods
/// Resolves the asset's path against the given base directory.
///
/// - Parameters:
/// - basePath: the directory the static files are served from.
/// - fileExtension: the extension of the file to resolve.
/// - Returns: the path to the file, relative to the `basePath` path.
func path(
relativeTo basePath: String,
for fileExtension: AssetExtension
) -> String {
let relativePath = relativePath(for: fileExtension)
guard !basePath.isEmpty else {
return relativePath
}
return "\(basePath)/\(relativePath)"
}
/// Resolves the asset's path relative to the static files root (e.g. `"css/shared.css"`).
///
/// This also matches the URL path the file is served at by `FileMiddleware`.
///
/// - Parameter fileExtension: the extension of the file to resolve.
/// - Returns: the path to the file, relative to the static files root.
func relativePath(
for fileExtension: AssetExtension
) -> String {
let file = "\(fileName).\(fileExtension.rawValue)"
return (folder ?? fileExtension.folder)
.map { "\($0)/\(file)" } ?? file
}
/// Resolves the absolute URL path the asset is served at (e.g. `"/css/shared.css"`).
///
/// A version token appends as a `v` query parameter (e.g. `"/css/shared.css?v=abc123"`): `FileMiddleware` ignores the query when
/// resolving the file, while caches key on the full URL, so a deploy that changes the assets busts every cached copy at once.
/// - Parameters:
/// - fileExtension: the extension of the file to resolve.
/// - version: the version token to append, or `nil` to leave the URL unversioned.
/// - Returns: the path to use in `href` and `src` attributes.
func urlPath(
for fileExtension: AssetExtension,
version: String? = nil
) -> String {
let path = "/\(relativePath(for: fileExtension))"
guard let version, !version.isEmpty else {
return path
}
return "\(path)?v=\(version)"
}
}
@@ -0,0 +1,14 @@
import Hummingbird
/// A request context that carries the language negotiated for the request.
///
/// ``LocalizationMiddleware`` resolves the visitor's preferred language from the `Accept-Language` header and stores it here, so downstream
/// controllers and middleware can serve the matching localization without re-reading the header.
public protocol LocalizedRequestContext: RequestContext {
// MARK: Properties
/// The language identifier negotiated for the request.
var language: String { get set }
}
@@ -0,0 +1,207 @@
import Elementary
import Foundation
/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
///
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, the analytics
/// tracker script, and the metadata followed by the stylesheet links and the deferred script tags in the head, and the content as the body.
public protocol Page: HTMLDocument, Sendable {
// MARK: Associated types
/// The type of the page's markup.
associatedtype Content: HTML
/// The type of the page's head metadata markup.
associatedtype Metadata: HTML
// MARK: Properties
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
var analytics: Analytics? { get }
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
var assetVersion: String? { get }
/// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag.
var canonicalURL: String? { get }
/// The page's markup, rendered as the document body.
@HTMLBuilder
var content: Content { get }
/// The locale the page content is localized to.
var locale: Locale { get }
/// The markup placed in the document head before the ``stylesheets`` links: icon and manifest links, extra meta tags, and the like.
@HTMLBuilder
var metadata: Metadata { get }
/// The scripts loaded from the document head, in order.
///
/// Rendered as `defer`red tags: the downloads start while the head is parsed, and the scripts still execute in order only after the
/// document is fully parsed the same semantics end-of-body tags would give, minus the late download start.
var scripts: [any Asset] { get }
/// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default)
/// to omit them.
var socialCard: SocialCard? { get }
/// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it.
var structuredData: StructuredData? { get }
/// The stylesheets linked in the document head, in order.
var stylesheets: [any Asset] { get }
/// The page's summary, rendered as a `meta name="description"` tag in the document head, or `nil` (the default) to omit the tag.
var summary: String? { get }
}
// MARK: - Implementations
public extension Page {
// MARK: Computed
/// The canonical URL is omitted unless the page provides one.
var canonicalURL: String? {
nil
}
/// The page ``content``; the ``scripts`` load deferred from the ``head``.
@HTMLBuilder
var body: some HTML {
content
}
/// The viewport declaration, the ``analytics`` origin preconnect hint, the ``summary``, ``canonicalURL``, and ``socialCard``
/// tags, the ``structuredData`` script and the ``analytics`` tracker script (when provided), and the ``metadata`` followed by the
/// ``stylesheets`` links and the deferred ``scripts`` tags, placed in the document head.
///
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` before this markup,
/// and HTML5 allows only one.
///
/// The structured data is an inert data block browsers never execute it, so a site's `Content-Security-Policy` does not apply to it
/// that search engines read for the organization's name, logo, and profiles. The analytics tracker, by contrast, is an executable script the
/// policy must allow, and it is `defer`red so it never delays the page render; each behavior flag renders its `data-` attribute only when enabled.
/// When recorder mode is on, the session recorder script follows the tracker script, deferred as well and carrying only the website id.
@HTMLBuilder
var head: some HTML {
meta(
.name(.viewport),
.content("width=device-width, initial-scale=1")
)
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
if let origin = analytics?.origin {
link(
.rel("preconnect"),
.href(origin)
)
}
if let summary {
meta(
.name(.description),
.content(summary)
)
}
if let canonicalURL {
link(
.rel("canonical"),
.href(canonicalURL)
)
}
if let socialCard {
for tag in socialCard.tags {
meta(
.custom(
name: tag.attribute.rawValue,
value: tag.name.rawValue
),
.content(tag.content)
)
}
}
if let structuredData {
script(.custom(
name: "type",
value: "application/ld+json"
)) {
HTMLRaw(structuredData.payload)
}
}
if let analytics {
script(
.defer,
.src(analytics.scriptURL)
) {}
.attributes(contentsOf: analytics.attributes.map {
.custom(
name: $0.name,
value: $0.value
)
})
if let recorderScriptURL = analytics.recorderScriptURL {
script(
.defer,
.src(recorderScriptURL),
.custom(
name: "data-website-id",
value: analytics.websiteID
)
) {}
}
}
metadata
for file in stylesheets {
link(
.rel(.stylesheet),
.href(file.urlPath(
for: .css,
version: assetVersion
))
)
}
for file in scripts {
script(
.defer,
.src(file.urlPath(
for: .js,
version: assetVersion
))
) {}
}
}
/// The analytics tracker is omitted unless the page provides one.
var analytics: Analytics? {
nil
}
/// The social card is omitted unless the page provides one.
var socialCard: SocialCard? {
nil
}
/// The structured data is omitted unless the page provides one.
var structuredData: StructuredData? {
nil
}
/// The summary is omitted unless the page provides one.
var summary: String? {
nil
}
}
@@ -0,0 +1,32 @@
import Hummingbird
/// A type exposing its endpoints as a route collection ready to be added to a router.
///
/// Conforming controllers group related endpoints behind a single ``routes`` property, so the application composes them declaratively with
/// ``Hummingbird/RouterMethods/addController(_:)``:
///
/// ```swift
/// struct HealthController<Context: RequestContext>: RouterController {
/// var routes: RouteCollection<Context> {
/// RouteCollection(context: Context.self)
/// .get("health") { _, _ in HTTPResponse.Status.ok }
/// }
/// }
///
/// router.addController {
/// HealthController<AppRequestContext>()
/// }
/// ```
public protocol RouterController<Context>: Sendable {
// MARK: Associated types
/// The request context the controller's routes operate on.
associatedtype Context: RequestContext
// MARK: Properties
/// The collection of routes the controller exposes.
var routes: RouteCollection<Context> { get }
}
@@ -0,0 +1,114 @@
import Elementary
import HTTPTypes
import Hummingbird
import NIOCore
/// A pre-rendered HTTP response for a fully static HTML page.
///
/// The document is rendered to bytes once, at initialization, and every ``response(for:)`` reuses those bytes along with a fixed status and
/// precomputed headers instead of re-rendering. This suits pages whose markup never changes between requests, such as the landing page and the
/// not-found page, avoiding a per-request Elementary render on hot paths.
///
/// A successful page also revalidates cheaply: its headers carry a weak entity tag derived from the rendered bytes and a `Cache-Control` that asks
/// clients to revalidate (`no-cache`), so a repeat visit costs a `304 Not Modified` instead of a full transfer and a deploy that changes the page
/// changes the tag, propagating immediately.
///
/// ``LocalizedHTMLCollectionResponse`` builds on this type, caching one instance per supported language.
///
/// The body is written as an unsized stream (no `Content-Length`), mirroring `HTMLResponse`, so the response-compression middleware downstream
/// treats it exactly as it would a freshly rendered page.
public struct CachedHTMLResponse: Sendable {
// MARK: Properties
/// The page rendered to bytes once.
private let buffer: ByteBuffer
/// The weak entity tag of the rendered bytes, present on successful pages only.
private let eTag: String?
/// The headers applied to every response, precomputed once.
private let headers: HTTPFields
/// The status applied to every response.
private let status: HTTPResponse.Status
// MARK: Initializers
/// Renders the given document to bytes once.
///
/// A `200 OK` page gets the revalidation headers (`ETag` and `Cache-Control`); an error page does not, since a `304 Not Modified` only
/// ever stands in for a success.
/// - Parameters:
/// - status: the status applied to every response. Defaults to `.ok`.
/// - additionalHeaders: extra headers merged onto every response, alongside the content type.
/// Used to carry per-language signals such as `Content-Language` and `Vary`.
/// - document: the static HTML document to render and cache.
public init(
status: HTTPResponse.Status = .ok,
additionalHeaders: HTTPFields = [:],
document: some HTMLDocument
) {
let buffer = ByteBuffer(string: document.render())
var headers: HTTPFields = [
.contentType: "text/html; charset=utf-8"
]
var eTag: String?
if status == .ok {
var hash = FNV1aHash()
hash.combine(buffer.readableBytesView)
eTag = "W/\"\(hash.digest)\""
headers[.eTag] = eTag
headers[.cacheControl] = "public, no-cache"
}
for field in additionalHeaders {
headers[field.name] = field.value
}
self.buffer = buffer
self.eTag = eTag
self.headers = headers
self.status = status
}
// MARK: Methods
/// Builds a response from the cached, pre-rendered bytes.
///
/// A conditional request whose `If-None-Match` names the page's entity tag is answered with a
/// bodyless `304 Not Modified`. Otherwise the full page is served, mirroring the
/// `text/html; charset=utf-8` content type `HTMLResponse` produces and leaving the
/// `Content-Length` unset so small pages remain eligible for compression.
/// - Parameter request: the request the response answers.
/// - Returns: the response carrying the cached HTML body, or its `304` revalidation.
public func response(
for request: Request
) -> Response {
if
let eTag,
request.method == .get || request.method == .head,
let match = request.headers[.ifNoneMatch],
match == "*" || match.contains(eTag)
{
return Response(
status: .notModified,
headers: headers
)
}
return Response(
status: status,
headers: headers,
body: .init { [buffer] writer in
try await writer.write(buffer)
try await writer.finish(nil)
}
)
}
}
@@ -0,0 +1,75 @@
import Elementary
import Foundation
import HTTPTypes
import Hummingbird
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.
public struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Properties
/// The supported languages and default language, derived from the bundle's String Catalog.
private let list: LanguageList
/// The pre-rendered responses, keyed by language identifier.
private let responses: [String: CachedHTMLResponse]
// MARK: Initializers
/// Renders the document once per supported language.
/// - 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`.
/// - document: builds the document to render for a given locale.
public init<Document: HTMLDocument>(
bundle: Bundle,
status: HTTPResponse.Status = .ok,
document: (Locale) -> Document
) {
self.list = .init(bundle: bundle)
self.responses = list.all
.reduce(into: [:]) { responses, language in
responses[language] = CachedHTMLResponse(
status: status,
additionalHeaders: [
.contentLanguage: language,
.vary: "Accept-Language",
],
document: document(.init(
identifier: language
))
)
}
}
// MARK: Methods
/// Builds the response for the given language, falling back to the default language.
/// - Parameters:
/// - language: the negotiated language identifier.
/// - request: the request the response answers, consulted for conditional revalidation.
/// - Returns: the cached response for the language, the default language's response when the language is unavailable, or a
/// `500 Internal Server Error` if neither is cached.
public func response(
for language: String,
request: Request
) -> Response {
guard
let response = responses[language] ?? responses[list.default]
else {
return .init(
status: .internalServerError
)
}
return response.response(
for: request
)
}
}
@@ -0,0 +1,115 @@
import Foundation
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
///
/// The behavior flags default to on and render their `data-` attributes only when enabled, since the tracker treats an absent attribute as off.
/// Recorder mode defaults to off; when on, the page embeds a second deferred script loading the session recorder from the tracker's origin.
public struct Analytics: Sendable {
// MARK: Properties
/// Whether the tracker drops the URL fragment from reported pageviews, so fragment navigation does not split a page's views.
public let excludeHash: Bool
/// Whether the tracker honors the visitor's browser Do Not Track preference.
public let doNotTrack: Bool
/// The comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host.
public let domains: String
/// Whether the tracker collects Core Web Vitals from visitors (requires an Umami instance at v3.1 or newer).
public let performance: Bool
/// Whether the tracker also records visitor sessions, loading the session recorder script alongside the tracker.
public let recorder: Bool
/// The URL the tracker script is loaded from.
public let scriptURL: String
/// The analytics website identifier the tracker reports as.
public let websiteID: String
// MARK: Initializers
/// Creates an analytics configuration.
/// - Parameters:
/// - scriptURL: the URL the tracker script is loaded from.
/// - websiteID: the analytics website identifier the tracker reports as.
/// - domains: the comma-delimited domains the tracker reports from; visits from any other host are ignored. Empty to report from every host.
/// - excludeHash: whether the tracker drops the URL fragment from reported pageviews; defaults to `true`.
/// - doNotTrack: whether the tracker honors the visitor's browser Do Not Track preference; defaults to `true`.
/// - performance: whether the tracker collects Core Web Vitals (requires Umami v3.1 or newer); defaults to `true`.
/// - recorder: whether the tracker also records visitor sessions, loading the session recorder script alongside the tracker; defaults to `false`.
public init(
scriptURL: String,
websiteID: String,
domains: String,
excludeHash: Bool = true,
doNotTrack: Bool = true,
performance: Bool = true,
recorder: Bool = false
) {
self.scriptURL = scriptURL
self.websiteID = websiteID
self.domains = domains
self.excludeHash = excludeHash
self.doNotTrack = doNotTrack
self.performance = performance
self.recorder = recorder
}
// MARK: Computed
/// The tracker script's attributes: the website id and reporting domains, 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)
]
if !domains.isEmpty {
attributes.append(.init("data-domains", value: domains))
}
if excludeHash {
attributes.append(.init("data-exclude-hash", value: "true"))
}
if doNotTrack {
attributes.append(.init("data-do-not-track", value: "true"))
}
if performance {
attributes.append(.init("data-performance", value: "true"))
}
return attributes
}
/// The origin the tracker is served from (scheme, host, and any explicit port), derived from the ``scriptURL``; `nil` when the URL
/// carries no scheme or host. Rendered as a `preconnect` hint before the tracker script.
public var origin: String? {
guard
let url = URL(string: scriptURL),
let scheme = url.scheme,
let host = url.host
else {
return nil
}
let port = url.port.map { ":\($0)" } ?? ""
return "\(scheme)://\(host)\(port)"
}
/// The URL the session recorder script is loaded from; `nil` when ``recorder`` mode is off or no ``origin`` can be derived.
/// Rendered as a second deferred script carrying only the `data-website-id` attribute.
public var recorderScriptURL: String? {
guard recorder, let origin else {
return nil
}
return "\(origin)/recorder.js"
}
}
@@ -0,0 +1,31 @@
extension Analytics {
/// A name-value pair rendered as an HTML attribute.
///
/// The ``name`` is a full `data-` attribute name applied verbatim except inside ``Analytics/Event/properties``, where it is the bare
/// key the event prefixes on render.
public struct Attribute: Equatable, Sendable {
// MARK: Properties
/// The attribute's name.
public let name: String
/// The attribute's value.
public let value: String
// MARK: Initializers
/// Creates a tracker attribute.
/// - Parameters:
/// - name: the attribute's name.
/// - value: the attribute's value.
public init(
_ name: String,
value: String
) {
self.name = name
self.value = value
}
}
}
@@ -0,0 +1,55 @@
extension Analytics {
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
///
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
public struct Event: Equatable, Sendable {
// MARK: Properties
/// The event's name, which is what a report groups by.
public let name: String
/// The event's properties, in declaration order, which is the order they render.
///
/// Each ``Attribute/name`` is the bare key `set`, not `data-umami-event-set`; ``attributes`` adds the prefix.
public let properties: [Attribute]
// MARK: Initializers
/// Creates an event.
/// - Parameters:
/// - name: the event's name, which is what a report groups by.
/// - properties: the event's properties as bare keys `set`, not `data-umami-event-set` rendered in the order written.
/// Empty by default.
public init(
name: String,
properties: KeyValuePairs<String, String> = [:]
) {
self.name = name
self.properties = properties.map {
.init($0.key, value: $0.value)
}
}
// MARK: Computed
/// The event's attributes the event name, then each property as full `data-` attribute names an element applies verbatim.
public var attributes: [Attribute] {
[.init(Constant.Name.prefix, value: name)]
+ properties.map {
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
}
}
}
}
// MARK: - Constants
private enum Constant {
enum Name {
/// The attribute name the tracker watches for, and the prefix each property renders under.
static let prefix = "data-umami-event"
}
}
@@ -0,0 +1,102 @@
/// The content of a page's link-preview card, rendered as Open Graph and Twitter meta tags in the document head.
///
/// The card carries the facts a link scraper reads the page's title, summary, URL, and share image and derives the meta ``tags`` expressing
/// them. Scrapers require absolute URLs, so the card takes ``url`` and ``Image/url`` fully formed; composing them from an origin and a versioned
/// asset path stays with the page providing the card.
public struct SocialCard: Sendable {
// MARK: Properties
/// The card's share image, or `nil` to omit its tags.
public let image: Image?
/// The locale of the card's text, or `nil` to omit its tag.
///
/// Open Graph specifies the `language_TERRITORY` form (e.g. `en_US`); scrapers also accept a bare language code (e.g. `en`).
public let locale: String?
/// The name of the site the card belongs to, or `nil` to omit its tag.
public let siteName: String?
/// The layout a Twitter card scraper gives the card.
public let style: Style
/// The card's summary, or `nil` to omit its tag.
public let summary: String?
/// The card's title.
public let title: String
/// The Open Graph type of the object the card describes.
public let type: String
/// The absolute URL the card's page is served at, or `nil` to omit its tag.
public let url: String?
// MARK: Initializers
/// Creates a link-preview card.
/// - Parameters:
/// - title: the card's title.
/// - summary: the card's summary, or `nil` (the default) to omit its tag.
/// - url: the absolute URL the card's page is served at, or `nil` (the default) to omit its tag.
/// - 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.
/// - 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``.
public init(
title: String,
summary: String? = nil,
url: String? = nil,
siteName: String? = nil,
locale: String? = nil,
image: Image? = nil,
type: String = "website",
style: Style = .summaryLargeImage
) {
self.image = image
self.locale = locale
self.siteName = siteName
self.style = style
self.summary = summary
self.title = title
self.type = type
self.url = url
}
// 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.
public var tags: [Tag] {
let tags: [Tag?] = [
Tag(type, name: .type),
siteName.map { Tag($0, name: .siteName) },
Tag(title, name: .title),
summary.map { Tag($0, name: .description) },
url.map { Tag($0, name: .url) },
locale.map { Tag($0, name: .locale) },
] + (image?.tags ?? []) + [
Tag(style.rawValue, name: .twitter),
]
return tags.compactMap { $0 }
}
}
// MARK: - Enumerations
public extension SocialCard {
/// The layout a Twitter card scraper gives a ``SocialCard``.
enum Style: String, Sendable {
/// A compact card with a small thumbnail.
case summary
/// A card with a large image above the text.
case summaryLargeImage = "summary_large_image"
}
}
@@ -0,0 +1,54 @@
extension SocialCard {
/// The share image of a ``SocialCard``.
public struct Image: Sendable {
// MARK: Properties
/// The image's text for assistive technologies, or `nil` to omit it.
public let alt: String?
/// The image's height in pixels, letting scrapers lay the card out before fetching the image.
public let height: Int
/// The absolute URL the image is served at.
public let url: String
/// The image's width in pixels, letting scrapers lay the card out before fetching the image.
public let width: Int
// MARK: Initializers
/// Creates a share image.
/// - Parameters:
/// - url: the absolute URL the image is served at.
/// - width: the image's width in pixels.
/// - height: the image's height in pixels.
/// - alt: the image's text for assistive technologies, or `nil` (the default) to omit it.
public init(
url: String,
width: Int,
height: Int,
alt: String? = nil
) {
self.alt = alt
self.height = height
self.url = url
self.width = width
}
// MARK: Computed
/// The image's meta tags, in a stable order: its URL, width, and height, then its alt text when it carries one.
public var tags: [Tag] {
let tags: [Tag?] = [
Tag(url, name: .image),
Tag(String(width), name: .imageWidth),
Tag(String(height), name: .imageHeight),
alt.map { Tag($0, name: .imageAlt) },
]
return tags.compactMap { $0 }
}
}
}
@@ -0,0 +1,88 @@
extension SocialCard {
/// A head meta tag of a ``SocialCard``: its name and content, keyed by the attribute its ``name`` dictates.
public struct Tag: Equatable, Sendable {
// MARK: Properties
/// The tag's value.
public let content: String
/// The tag's name.
public let name: Name
// MARK: Initializers
/// Creates a head meta tag.
/// - Parameters:
/// - content: the tag's value.
/// - name: the tag's name, dictating the attribute the tag is keyed by.
public init(
_ content: String,
name: Name
) {
self.content = content
self.name = name
}
// MARK: Computed
/// The attribute the tag is keyed by, dictated by its ``name``.
public var attribute: Attribute {
name.attribute
}
}
}
// MARK: - Enumerations
extension SocialCard.Tag {
/// The meta attribute a ``SocialCard/Tag`` is keyed by, named by its raw value.
public enum Attribute: String, Sendable {
/// The `name` attribute, keying the Twitter tags.
case name
/// The `property` attribute, keying the Open Graph tags.
case property
}
/// The name of a ``SocialCard/Tag``, carried in its raw value.
public enum Name: String, Sendable {
/// The `og:description` tag, carrying the card's summary.
case description = "og:description"
/// The `og:image` tag, carrying the share image's absolute URL.
case image = "og:image"
/// The `og:image:alt` tag, carrying the share image's text for assistive technologies.
case imageAlt = "og:image:alt"
/// The `og:image:height` tag, carrying the share image's height in pixels.
case imageHeight = "og:image:height"
/// The `og:image:width` tag, carrying the share image's width in pixels.
case imageWidth = "og:image:width"
/// The `og:locale` tag, carrying the locale of the card's text.
case locale = "og:locale"
/// 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.
case title = "og:title"
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
case twitter = "twitter:card"
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
case type = "og:type"
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
case url = "og:url"
}
}
// MARK: - Implementations
public extension SocialCard.Tag.Name {
// MARK: Computed
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
var attribute: SocialCard.Tag.Attribute {
self == .twitter ? .name : .property
}
}
@@ -0,0 +1,106 @@
/// The structured data of a page, rendered as a JSON-LD script in the document head.
///
/// The data is a graph of schema.org ``Node`` values each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
/// order and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
///
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
/// page providing the data.
public struct StructuredData: Equatable, Sendable {
// MARK: Properties
/// The schema.org nodes of the data's graph, in the order they render.
public let nodes: [Node]
// MARK: Initializers
/// Creates structured data from the nodes of its graph.
/// - Parameter nodes: the schema.org nodes of the data's graph, in the order they render.
public init(
nodes: [Node]
) {
self.nodes = nodes
}
// MARK: Computed
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
///
/// The values are rendered as JSON string literals with `<` escaped as well, so a value can never close the `script` tag embedding
/// the payload.
public var payload: String {
#"{"@context":"https://schema.org","@graph":[\#(fragments)]}"#
}
}
// MARK: - Initializers
public extension StructuredData {
/// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
/// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
/// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
/// A property whose fact the data does not carry is left out.
/// - Parameters:
/// - name: the name of the organization and the site.
/// - url: the absolute URL the site is served at.
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
init(
name: String,
url: String,
logo: String? = nil,
profiles: [String] = []
) {
let id = url + "#organization"
var organization: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
]
if let logo {
organization.append(.init(.logo, value:.string(logo)))
}
if !profiles.isEmpty {
organization.append(.init(
.sameAs,
value: .array(profiles.map(Value.string))
))
}
self.init(nodes: [
.init(
type: .organization,
id: id,
properties: organization
),
.init(
type: .website,
properties: [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
]
),
])
}
}
// MARK: - Helpers
private extension StructuredData {
// MARK: Computed
var fragments: String {
nodes
.map(\.fragment)
.joined(separator: .Separator.comma)
}
}
@@ -0,0 +1,92 @@
extension StructuredData {
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
public struct Node: Equatable, Sendable {
// MARK: Properties
/// The node's identifier, rendered as its `@id` property, or `nil` to omit it.
///
/// Another node references this node through ``Value/reference(_:)`` with the same identifier.
public let id: String?
/// The node's properties, in the order they render after the type and identifier.
public let properties: [Property]
/// The node's schema.org type (e.g. ``Kind/organization``), rendered as its `@type` property.
public let type: Kind
// MARK: Initializers
/// Creates a node.
/// - Parameters:
/// - type: the node's schema.org type (e.g. ``Kind/organization``).
/// - id: the node's identifier, or `nil` (the default) to omit it.
/// - properties: the node's properties, in the order they render.
public init(
type: Kind,
id: String? = nil,
properties: [Property]
) {
self.id = id
self.properties = properties
self.type = type
}
// MARK: Computed
/// The node's minified JSON object: the `@type`, the `@id` (when the node carries one), and the ``properties`` in order.
var fragment: String {
var members = [#""@type":\#(Value.literal(type.rawValue))"#]
if let id {
members.append(#""@id":\#(Value.literal(id))"#)
}
members += properties.map(\.fragment)
return "{\(members.joined(separator: .Separator.comma))}"
}
}
}
// MARK: - Structures
extension StructuredData.Node {
/// The schema.org type of a ``StructuredData/Node``.
///
/// Schema.org's vocabulary is open, so the kind is a typed string rather than a closed enumeration: the kinds every service shares
/// come as constants, a service declares the kinds its own node shapes need in an extension, and a one-off kind can be spelled as a
/// string literal.
public struct Kind: Equatable, ExpressibleByStringLiteral, Sendable {
// MARK: Properties
/// The type as it renders in the payload.
public let rawValue: String
// MARK: Initializers
/// Creates a kind.
/// - Parameter rawValue: the type as it renders in the payload.
public init(_ rawValue: String) {
self.rawValue = rawValue
}
/// Creates a kind from a string literal.
/// - Parameter value: the type as it renders in the payload.
public init(stringLiteral value: String) {
self.init(value)
}
}
}
// MARK: - Constants
public extension StructuredData.Node.Kind {
/// An organization, e.g. the one publishing a website.
static let organization: Self = "Organization"
/// A website.
static let website: Self = "WebSite"
}
@@ -0,0 +1,82 @@
extension StructuredData {
/// A named property of a ``Node``, in the position it renders.
public struct Property: Equatable, Sendable {
// MARK: Properties
/// The property's schema.org name (e.g. `sameAs`).
public let name: Name
/// The property's value.
public let value: Value
// MARK: Initializers
/// Creates a property.
/// - Parameters:
/// - name: the property's schema.org name (e.g. `sameAs`).
/// - value: the property's value.
public init(
_ name: Name,
value: Value
) {
self.name = name
self.value = value
}
// MARK: Computed
/// The property's minified JSON member: its name and its rendered value.
var fragment: String {
#"\#(Value.literal(name.rawValue)):\#(value.fragment)"#
}
}
}
// MARK: - Structures
extension StructuredData.Property {
/// The schema.org name of a ``StructuredData/Property``.
///
/// Schema.org's vocabulary is open, so the name is a typed string rather than a closed enumeration: the names every service shares
/// come as constants, a service declares the names its own node shapes need in an extension, and a one-off name can be spelled as a
/// string literal.
public struct Name: Equatable, ExpressibleByStringLiteral, Sendable {
// MARK: Properties
/// The name as it renders in the payload.
public let rawValue: String
// MARK: Initializers
/// Creates a name.
/// - Parameter rawValue: the name as it renders in the payload.
public init(_ rawValue: String) {
self.rawValue = rawValue
}
/// Creates a name from a string literal.
/// - Parameter value: the name as it renders in the payload.
public init(stringLiteral value: String) {
self.init(value)
}
}
}
// MARK: - Constants
public extension StructuredData.Property.Name {
/// The absolute URL of an organization's logo.
static let logo: Self = "logo"
/// The name of the thing a node describes.
static let name: Self = "name"
/// The organization publishing a website.
static let publisher: Self = "publisher"
/// The absolute URLs of the profiles that also identify the thing a node describes.
static let sameAs: Self = "sameAs"
/// The absolute URL of the thing a node describes.
static let url: Self = "url"
}
@@ -0,0 +1,67 @@
extension StructuredData {
/// A value of a ``Property``: a string, 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.
public indirect enum Value: Equatable, Sendable {
/// A list of values.
case array([Value])
/// A nested node, e.g. the place a schema.org event is located at.
case node(Node)
/// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object.
case reference(String)
/// A string value.
case string(String)
}
}
// MARK: - Extensions
extension StructuredData.Value {
// MARK: Computed
/// The value's minified JSON fragment.
var fragment: String {
switch self {
case .array(let values):
"[\(values.map(\.fragment).joined(separator: .Separator.comma))]"
case .node(let node):
node.fragment
case .reference(let id):
#"{"@id":\#(Self.literal(id))}"#
case .string(let string):
Self.literal(string)
}
}
// MARK: Methods
/// Renders a string as a JSON string literal, escaping `<` as well since the payload is embedded in a `script` tag the string
/// could otherwise close.
/// - Parameter value: the string to render.
/// - Returns: the quoted and escaped literal.
static func literal(_ value: String) -> String {
var literal = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\"":
literal += #"\""#
case "\\":
literal += #"\\"#
case "<":
literal += #"\u003c"#
case let scalar where scalar.value < 0x20:
let hex = String(scalar.value, radix: 16)
literal += #"\u"# + String(repeating: "0", count: 4 - hex.count) + hex
default:
literal.unicodeScalars.append(scalar)
}
}
return literal + "\""
}
}