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
+2 -2
View File
@@ -5,11 +5,11 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
| Role | Types |
| --- | --- |
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware` (negotiating from a `lang` query parameter, then a leading path segment, then `Accept-Language`), `NotFoundMiddleware` |
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| Link previews | `SocialCard`, its `Image` and `Style`, and the `Tag` meta tags it derives |
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair |
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to a tag), the tracker script `Attribute`s it derives, and the `origin` its preconnect hint targets |
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to a tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
| Contexts | `LocalizedRequestContext` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
+28 -10
View File
@@ -3,8 +3,8 @@ The **Site** public website service — a [Hummingbird](https://github.com/hummi
## Overview
The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
- Negotiates each request's language from its `Accept-Language` header against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)).
- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`); pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
- Answers `HEAD` on every `GET` route: the router is built with `.autoGenerateHeadEndpoints`, so uptime monitors and crawlers probing with `HEAD` get the route's status and headers instead of a `404`.
@@ -43,10 +43,10 @@ LogRequestsMiddleware
→ TrailingSlashRedirectMiddleware (301 to the path without a trailing slash)
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
→ LocalizationMiddleware (negotiates the request's language)
→ LocalizationMiddleware (negotiates the language: ?lang=, path prefix, then Accept-Language)
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page)
RootController (GET / → landing page; GET /<lang> → its other editions)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
@@ -55,20 +55,38 @@ The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET`
### Page metadata
Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
Four of those are page-authored, optional, and **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
Four of those are page-authored and optional. `IndexPage` supplies `canonicalURL`; the other three are **omitted by default** — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on `IndexPage` (or on the shared `Page+Defaults` extension, for site-wide values):
| Property | Renders as | Notes |
| --- | --- | --- |
| `summary` | `<meta name="description">` | The page's one-line description. |
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. |
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin. |
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). |
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:profiles:)` builds the site-wide `Organization` + `WebSite` pair. The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the `preloadedFonts` links, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas. Those live in `siteMetadata`, which `metadata` returns unchanged — a page that adds head tags of its own composes `siteMetadata` rather than replacing it.
What the pages *do* ship is in `Page+Defaults` (`Sources/Library/Internal/Extensions`), shared by every page: the document `lang`, the `ogLocale` a social card would carry, the `preloadedFonts` links, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas. The last group lives in `siteMetadata`, which `metadata` returns unchanged — a page that adds head tags of its own composes `siteMetadata` rather than replacing it.
`ogLocale` renders Open Graph's `language_TERRITORY` form by looking the page's `lang` up in the `ogLocales` map, which ships one pairing: `en``en_US`. 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. Nothing reads it until a page supplies a `socialCard`.
`preloadedFonts` is empty until the site ships fonts. Listing one emits `<link rel="preload" as="font" crossorigin>` at `/font/<name>.woff2`, deliberately unversioned: a preload URL must match the stylesheet's `@font-face` source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a `unicode-range` no page reaches would add a download that never otherwise happens.
### Language editions
The `WebsiteLibrary` String Catalog is the single source of truth for the languages the site serves: add a localization and it appears, with no code change. `Language` (`Sources/Library/Internal/Types`) reads that list and derives each language's URLs from it.
The catalog's source language is the **default** and owns the site's bare paths; every other language answers under a prefix of its own. The root collapses onto the prefix alone, so a Dutch home is `/nl`, not `/nl/` — the spelling `TrailingSlashRedirectMiddleware` redirects away from anyway:
| Language | Landing page | A page at `/privacy` |
| --- | --- | --- |
| `en` (default) | `/` | `/privacy` |
| `nl` | `/nl` | `/nl/privacy` |
`RootController` registers the bare route plus one per non-default language. A prefixed route answers in *its* language for every visitor and every crawler — the path is the language choice, so the negotiated context language is ignored, which is what lets a search engine index it as that edition. The template ships an English-only catalog, so it registers the bare route alone.
Given a `site.origin`, each page then emits the `hreflang` alternates tying its editions together — one per language plus an `x-default` pointing at the default language's edition, whose bare URL negotiates the language and so is the right landing for everyone unmatched. A single-language site emits none: a set naming one edition tells a search engine nothing it cannot already see. `Page+Defaults`' `languageAlternates(origin:path:languages:)` takes the language set, so a page translated into only some of them narrows it rather than advertising an edition that does not exist.
Visitors switch language two ways, both handled by `LocalizationMiddleware` ahead of the routes: a `?lang=` query parameter (what a language switcher links to) and the leading path segment. Either beats `Accept-Language`; a value naming no supported language is ignored. The path segment matters beyond the routed pages — it is what makes an *unrouted* path under a language's prefix answer its not-found page in that language.
## Configuration
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
@@ -134,9 +152,9 @@ every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the fo
### Site
| Config key | Environment variable | Default | Description |
| --- | --- | --- | --- |
| `site.origin` | `SITE_ORIGIN` | _(set by bootstrap)_ | The public origin the site is served at (scheme and host, no trailing slash). The HTTPS redirect points at it, and absolute links derive from it. |
| `site.origin` | `SITE_ORIGIN` | _(set by bootstrap)_ | The public origin the site is served at (scheme and host, no trailing slash). The HTTPS redirect points at it, and the pages' `rel="canonical"` links and `hreflang` alternates derive from it. |
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](#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.
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](#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. An empty origin also leaves the pages without canonical URLs and language alternates, rather than building either against an empty host.
### Logging
| Config key | Environment variable | Default | Description |
@@ -57,6 +57,9 @@ func application(
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)
),
@@ -141,9 +144,10 @@ private func logger(
/// 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 not-found 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 not-found page, and the served static files. The HTTPS redirect sits directly beneath it, so a redirect carries the security
@@ -158,6 +162,7 @@ private func logger(
/// - 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.
@@ -170,6 +175,7 @@ private func router(
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
siteOrigin: String?,
logLevel: Logger.Level,
probe: Probe
) -> Router<AppRequestContext> {
@@ -207,6 +213,7 @@ private func router(
router.addController {
RootController<AppRequestContext>(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
)
HealthController<AppRequestContext>(
@@ -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)
}
}
@@ -0,0 +1,128 @@
import Elementary
import Foundation
import Infrastructure
import Testing
@testable import WebsiteLibrary
@Suite(
"Page+Defaults extension",
.tags(.extensionTests)
)
struct PageDefaultsTests {
// MARK: Computed tests
@Test
func `pairs english with a territory for its social card locale`() {
#expect(StubPage().ogLocale == "en_US")
}
/// Open Graph prefers `language_TERRITORY`, but a scraper accepts the bare code better than pinning a territory the site never named.
@Test
func `leaves an unpaired language as a bare code`() {
#expect(StubPage(locale: .init(identifier: "nl")).ogLocale == "nl")
}
// MARK: Method tests
@Test
func `renders no language alternates without an origin`() {
let html = StubPage().languageAlternates(
origin: nil,
path: "/",
languages: Self.languages
).render()
#expect(html.isEmpty)
}
@Test
func `renders no language alternates for a single language`() {
let html = StubPage().languageAlternates(
origin: "https://example.com",
path: "/",
languages: [.default]
).render()
#expect(html.isEmpty)
}
/// The default language keeps the bare root; the prefixed edition collapses onto its prefix rather than carrying a trailing slash.
@Test
func `renders an alternate per language and an x-default at the root`() {
let html = StubPage().languageAlternates(
origin: "https://example.com",
path: "/",
languages: Self.languages
).render()
#expect(html.contains(#"<link rel="alternate" hreflang="en" href="https://example.com">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="nl" href="https://example.com/nl">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="x-default" href="https://example.com">"#))
}
@Test
func `prefixes the alternates of a nested page`() {
let html = StubPage().languageAlternates(
origin: "https://example.com",
path: "/privacy",
languages: Self.languages
).render()
#expect(html.contains(#"<link rel="alternate" hreflang="en" href="https://example.com/privacy">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="nl" href="https://example.com/nl/privacy">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="x-default" href="https://example.com/privacy">"#))
}
}
// MARK: - Helpers
private extension PageDefaultsTests {
// MARK: Constants
/// A two-language set, standing in for the multi-language catalog the template itself does not ship.
static let languages: [Language] = [
.default,
.init(identifier: "nl")
]
// MARK: Types
/// The barest ``Page`` conformance, so the shared defaults can be exercised without a real page's content.
struct StubPage: Page {
// MARK: Properties
let assetVersion: String? = nil
let locale: Locale
// MARK: Initializers
init(locale: Locale = .init(identifier: "en")) {
self.locale = locale
}
// MARK: Properties
var content: some HTML {
HTMLRaw("")
}
var scripts: [any Asset] {
[]
}
var stylesheets: [any Asset] {
[]
}
var title: String {
"Stub"
}
}
}
@@ -39,6 +39,32 @@ struct IndexPageTests {
#expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="preload""#))
}
@Test
func `renders no canonical URL until an origin is given`() {
#expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="canonical""#))
}
@Test
func `renders the canonical URL as the bare origin for the default language`() {
// The root is the one path with no trailing slash for `TrailingSlashRedirectMiddleware` to strip.
let html = IndexPage(
locale: .init(identifier: "en"),
siteOrigin: "https://example.com"
).render()
#expect(html.contains(#"<link rel="canonical" href="https://example.com">"#))
}
@Test
func `renders no language alternates for a single-language catalog`() {
// The template serves one language, so a set naming one edition would tell a crawler nothing.
#expect(Language.all.count == 1)
#expect(!IndexPage(
locale: .init(identifier: "en"),
siteOrigin: "https://example.com"
).render().contains("hreflang"))
}
@Test
func `renders versioned asset URLs when given a version`() {
let html = IndexPage(
@@ -0,0 +1,109 @@
import Foundation
import Testing
@testable import WebsiteLibrary
@Suite(
"Language type",
.tags(.type)
)
struct LanguageTests {
// MARK: Catalog tests
/// The template ships an English-only catalog, so a site adding a language sees this pin fail and reviews the URL rules below.
@Test
func `derives its languages from the String Catalog`() {
#expect(Language.all == [Language(identifier: "en")])
#expect(Language.default == Language(identifier: "en"))
}
// MARK: Initializer tests
@Test
func `reads the language a locale names`() {
#expect(Language(of: .init(identifier: "en")).identifier == "en")
}
@Test
func `reads a regional locale as its primary language`() {
#expect(Language(of: .init(identifier: "en_GB")).identifier == "en")
}
@Test
func `falls back to the default for a language the catalog does not serve`() {
#expect(Language(of: .init(identifier: "fr")) == .default)
}
// MARK: Computed tests
@Test
func `owns the bare paths as the default language`() {
let language = Language.default
#expect(language.isDefault)
#expect(language.pathPrefix.isEmpty)
}
@Test
func `prefixes its paths as a non-default language`() {
let language = Language(identifier: "nl")
#expect(!language.isDefault)
#expect(language.pathPrefix == "/nl")
}
// MARK: Method tests
@Test(arguments: zip(
["/", "/privacy"],
["/", "/privacy"]
))
func `path`(
forDefaultLanguageAt barePath: String,
expects path: String
) {
#expect(Language.default.path(barePath) == path)
}
/// The root collapses onto the prefix alone: a trailing slash is the very spelling `TrailingSlashRedirectMiddleware` redirects away from.
@Test(arguments: zip(
["/", "/privacy"],
["/nl", "/nl/privacy"]
))
func `path`(
forPrefixedLanguageAt barePath: String,
expects path: String
) {
#expect(Language(identifier: "nl").path(barePath) == path)
}
@Test(arguments: zip(
["/", "/privacy"],
["https://example.com", "https://example.com/privacy"]
))
func `url`(
forDefaultLanguageAt barePath: String,
expects url: String
) {
#expect(Language.default.url(
origin: "https://example.com",
path: barePath
) == url)
}
@Test(arguments: zip(
["/", "/privacy"],
["https://example.com/nl", "https://example.com/nl/privacy"]
))
func `url`(
forPrefixedLanguageAt barePath: String,
expects url: String
) {
#expect(Language(identifier: "nl").url(
origin: "https://example.com",
path: barePath
) == url)
}
}
@@ -131,6 +131,37 @@ struct RootControllerTests {
}
}
@Test
func `serves the landing page with a canonical URL when an origin is configured`() async throws {
try await app(
siteOrigin: "https://example.com"
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
#expect(String(buffer: response.body).contains(#"<link rel="canonical" href="https://example.com">"#))
}
}
}
/// The template's catalog serves one language, so the default owns every path and no prefixed route is registered.
@Test
func `registers no prefixed route for a single-language catalog`() async throws {
let prefixed = Language.all.filter { !$0.isDefault }
#expect(prefixed.isEmpty)
try await app.test(.router) { client in
try await client.execute(
uri: "/en",
method: .get
) { response in
#expect(response.status == .notFound)
}
}
}
@Test
func `embeds no analytics tracker by default`() async throws {
try await app.test(.router) { client in
@@ -176,13 +207,15 @@ private extension RootControllerTests {
// MARK: Methods
/// Builds an application whose root controller appends the given version token to the landing
/// page's asset URLs and embeds the given analytics tracker.
/// page's asset URLs, derives its absolute links from the given origin, and embeds the given analytics tracker.
/// - Parameters:
/// - assetVersion: the version token appended to the page's asset URLs.
/// - 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.
/// - Returns: the configured application.
func app(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
) -> some ApplicationProtocol {
let router = Router(context: WebsiteRequestContext.self)
@@ -193,6 +226,7 @@ private extension RootControllerTests {
router.addRoutes(RootController<WebsiteRequestContext>(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
).routes)
@@ -5,6 +5,8 @@ extension Tag {
@Tag static var controller: Tag
/// Tests exercising an enumeration of the Website library.
@Tag static var enumeration: Tag
/// Tests exercising an extension of the Website library.
@Tag static var extensionTests: Tag
/// Tests exercising a page of the Website library.
@Tag static var page: Tag
/// Tests exercising a type of the Website library.