Added language support to the Website service target.
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user