Merged the template branch into main to pick up the 33 upstream changes.

Reconciled the bootstrap-customised files: kept the CCN naming, canonical
origin, database slug and analytics comments, dropped the template-only
Makefile, README.md and Scripts/bootstrap that bootstrap removes, and took
the template's ordering for the security headers in the production compose.
This commit is contained in:
2026-08-30 22:57:45 +02:00
54 changed files with 2273 additions and 309 deletions
@@ -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"
}
}
@@ -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 = ""
}
}