Added language support to the Website service target.

This commit is contained in:
2026-08-30 21:47:08 +02:00
parent 9b16ce3339
commit a3d9655766
12 changed files with 568 additions and 17 deletions
@@ -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)
}
}