Files
ccn/Services/Website/Sources/Library/Public/Controllers/RootController.swift
T

182 lines
6.2 KiB
Swift

import Foundation
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Localization
/// Serves the website's root routes.
///
/// The controller exposes its routes through its `RouterController` conformance, so the application that composes it registers them declaratively:
///
/// ```swift
/// router.addController {
/// RootController<AppRequestContext>()
/// }
/// ```
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct RootController<Context: RequestContext> {
// MARK: Properties
/// What a page rendered per request is built from, the same as the cached ones.
private let page: @Sendable (Locale, Viewer?) -> IndexPage
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
private let sessions: SessionStore?
/// Negotiates the language the bare route answers in.
private let negotiate: Negotiate
/// The landing page, rendered once per supported language and reused for every request.
private let responses: LocalizedHTMLCollectionResponse
// MARK: Initializers
/// 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.
/// - listings: the listings the landing page shows in its rows; none by default.
/// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to serve everyone the cached page.
public init(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil,
listings: HomeListings = .init(),
sessions: SessionStore? = nil
) {
let page: @Sendable (Locale, Viewer?) -> IndexPage = {
.init(
locale: $0,
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
listings: listings,
viewer: $1
)
}
self.negotiate = .init(bundle: .module)
self.page = page
self.sessions = sessions
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
// and carry it too.
self.responses = .init(
bundle: .module,
variesOnAcceptLanguage: true
) {
page($0, nil)
}
}
}
// MARK: - RouteController
extension RootController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(
.Root.index,
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
}
}
// MARK: - Helpers
private extension RootController {
// MARK: Methods
/// Handles a request for the landing page.
///
/// Renders the ``IndexPage`` in the language negotiated from the request — its `lang` query parameter, then the leading path segment, then
/// `Accept-Language` — falling back to the default language.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - Returns: the cached ``IndexPage`` response for the negotiated language.
@Sendable
func index(
request: Request,
context: Context
) async -> Response {
await respond(
in: negotiate(for: request),
request: request,
context: context
)
}
/// 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 nothing is negotiated: 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) async -> Response {
{ request, context in
await respond(
in: language.identifier,
request: request,
context: context
)
}
}
/// Answers with the landing page in the given language: the cached page signed out, and one rendered around the viewer signed in.
/// - Parameters:
/// - language: the identifier of the language to answer in.
/// - request: the incoming request.
/// - context: the context the request is resolved against.
func respond(
in language: String,
request: Request,
context: Context
) async -> Response {
guard let viewer = await sessions?.viewer(for: request) else {
return responses.response(
for: language,
request: request
)
}
return HTMLResponse(additionalHeaders: .perViewer) { [page] in
page(.init(identifier: language), viewer)
}
.response(from: request, context: context)
}
}
// MARK: - Constants
private extension RouterPath {
/// A namespace for the ``RootController`` route paths.
enum Root {
/// The path of the landing page; the page builds its canonical URL from the same constant.
static let index: RouterPath = .init(IndexPage.path)
}
}