Added language support to the Website service target.
This commit is contained in:
@@ -16,6 +16,14 @@ extension Page {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -24,11 +32,65 @@ extension Page {
|
||||
?? LanguageList().default
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user