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() /// } /// ``` /// /// - 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 { // MARK: Properties /// The landing pages. Whatever changes the catalogue refreshes them through it. public let home: HomePages /// Negotiates the language the bare route answers in. private let negotiate: Negotiate /// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page. private let sessions: SessionStore? // 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 ) { self.home = .init( assetVersion: assetVersion, siteOrigin: siteOrigin, analytics: analytics, listings: listings ) self.negotiate = .init(bundle: .module) self.sessions = sessions } } // MARK: - RouteController extension RootController: RouterController { // MARK: Properties public var routes: RouteCollection { 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 await home.response( in: language, request: request ) } let page = await home.page( in: language, for: viewer ) return HTMLResponse(additionalHeaders: .perViewer) { page } .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) } }