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 |
|
| Role | Types |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
| 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` |
|
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||||
| Link previews | `SocialCard`, its `Image` and `Style`, and the `Tag` meta tags it derives |
|
| 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 |
|
| 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` |
|
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
||||||
| Contexts | `LocalizedRequestContext` |
|
| Contexts | `LocalizedRequestContext` |
|
||||||
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
| 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
|
## Overview
|
||||||
The service:
|
The service:
|
||||||
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached).
|
- 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 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.
|
- 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)).
|
- 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 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`.
|
- 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)
|
→ TrailingSlashRedirectMiddleware (301 to the path without a trailing slash)
|
||||||
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
||||||
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
→ 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)
|
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
|
||||||
→ FileMiddleware (serves Resources/Static)
|
→ 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)
|
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
|
### 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.
|
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 |
|
| Property | Renders as | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `summary` | `<meta name="description">` | The page's one-line description. |
|
| `summary` | `<meta name="description">` | The page's one-line description. |
|
||||||
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. |
|
| `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. |
|
| `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. |
|
| `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.
|
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.
|
`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
|
||||||
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration), **highest precedence first**:
|
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`)
|
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
|
### Site
|
||||||
| Config key | Environment variable | Default | Description |
|
| 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
|
### Logging
|
||||||
| Config key | Environment variable | Default | Description |
|
| Config key | Environment variable | Default | Description |
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ func application(
|
|||||||
httpsRedirect: reader.httpsRedirect,
|
httpsRedirect: reader.httpsRedirect,
|
||||||
rateLimit: reader.rateLimit,
|
rateLimit: reader.rateLimit,
|
||||||
securityHeaders: reader.securityHeaders,
|
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,
|
logLevel: reader.logLevel,
|
||||||
probe: Probe(fluent: fluent)
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// - 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.
|
/// - rateLimit: the rate limit configuration, currently applied to no route.
|
||||||
/// - securityHeaders: the security headers applied to every response.
|
/// - 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.
|
/// - logLevel: the level the request-logging middleware logs at.
|
||||||
/// - probe: the probe consulted by the `HealthController` readiness route.
|
/// - probe: the probe consulted by the `HealthController` readiness route.
|
||||||
/// - Returns: the configured router.
|
/// - Returns: the configured router.
|
||||||
@@ -170,6 +175,7 @@ private func router(
|
|||||||
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
|
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
|
||||||
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
||||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||||
|
siteOrigin: String?,
|
||||||
logLevel: Logger.Level,
|
logLevel: Logger.Level,
|
||||||
probe: Probe
|
probe: Probe
|
||||||
) -> Router<AppRequestContext> {
|
) -> Router<AppRequestContext> {
|
||||||
@@ -207,6 +213,7 @@ private func router(
|
|||||||
router.addController {
|
router.addController {
|
||||||
RootController<AppRequestContext>(
|
RootController<AppRequestContext>(
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
|
siteOrigin: siteOrigin,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
)
|
)
|
||||||
HealthController<AppRequestContext>(
|
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
|
// MARK: Computed
|
||||||
|
|
||||||
/// The document language, derived from the page's locale and falling back to the default language.
|
/// The document language, derived from the page's locale and falling back to the default language.
|
||||||
@@ -24,11 +32,65 @@ extension Page {
|
|||||||
?? LanguageList().default
|
?? 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.
|
/// The site-wide head metadata; a page that adds tags of its own composes ``siteMetadata`` rather than replacing it.
|
||||||
var metadata: some HTML {
|
var metadata: some HTML {
|
||||||
siteMetadata
|
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.
|
/// 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
|
/// 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.
|
/// The locale the page content is localized to.
|
||||||
let locale: Locale
|
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``.
|
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
|
||||||
private let localize: Localize
|
private let localize: Localize
|
||||||
|
|
||||||
@@ -26,15 +29,18 @@ struct IndexPage {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - locale: the locale the page content is localized to.
|
/// - 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.
|
/// - 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.
|
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||||
init(
|
init(
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
|
siteOrigin: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
) {
|
) {
|
||||||
self.analytics = analytics
|
self.analytics = analytics
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
|
self.siteOrigin = siteOrigin
|
||||||
self.localize = .init(bundle: .module)
|
self.localize = .init(bundle: .module)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +50,36 @@ struct IndexPage {
|
|||||||
|
|
||||||
extension IndexPage: Page {
|
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
|
// MARK: Properties
|
||||||
|
|
||||||
var content: some HTML {
|
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.
|
/// Creates a root controller.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
/// - 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.
|
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||||
public init(
|
public init(
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
|
siteOrigin: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
) {
|
) {
|
||||||
self.responses = .init(bundle: .module) {
|
self.responses = .init(bundle: .module) {
|
||||||
IndexPage(
|
IndexPage(
|
||||||
locale: $0,
|
locale: $0,
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
|
siteOrigin: siteOrigin,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,15 @@ extension RootController: RouterController {
|
|||||||
use: index
|
use: index
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Every non-default language answers under a prefix of its own, so the `hreflang` alternates the page advertises
|
||||||
|
// resolve and a crawler can index each edition at a stable URL. A single-language catalog adds none.
|
||||||
|
for language in Language.all where !language.isDefault {
|
||||||
|
routes.get(
|
||||||
|
.init(language.path(IndexPage.path)),
|
||||||
|
use: index(in: language)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return routes
|
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
|
// MARK: - Constants
|
||||||
@@ -91,7 +120,7 @@ private extension RootController {
|
|||||||
private extension RouterPath {
|
private extension RouterPath {
|
||||||
/// A namespace for the ``RootController`` route paths.
|
/// A namespace for the ``RootController`` route paths.
|
||||||
enum Root {
|
enum Root {
|
||||||
/// The path of the landing page.
|
/// The path of the landing page; the page builds its canonical URL from the same constant.
|
||||||
static let index: RouterPath = "/"
|
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""#))
|
#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
|
@Test
|
||||||
func `renders versioned asset URLs when given a version`() {
|
func `renders versioned asset URLs when given a version`() {
|
||||||
let html = IndexPage(
|
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
|
@Test
|
||||||
func `embeds no analytics tracker by default`() async throws {
|
func `embeds no analytics tracker by default`() async throws {
|
||||||
try await app.test(.router) { client in
|
try await app.test(.router) { client in
|
||||||
@@ -176,13 +207,15 @@ private extension RootControllerTests {
|
|||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
/// Builds an application whose root controller appends the given version token to the landing
|
/// 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:
|
/// - Parameters:
|
||||||
/// - assetVersion: the version token appended to the page's asset URLs.
|
/// - 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.
|
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
|
||||||
/// - Returns: the configured application.
|
/// - Returns: the configured application.
|
||||||
func app(
|
func app(
|
||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
|
siteOrigin: String? = nil,
|
||||||
analytics: Analytics? = nil
|
analytics: Analytics? = nil
|
||||||
) -> some ApplicationProtocol {
|
) -> some ApplicationProtocol {
|
||||||
let router = Router(context: WebsiteRequestContext.self)
|
let router = Router(context: WebsiteRequestContext.self)
|
||||||
@@ -193,6 +226,7 @@ private extension RootControllerTests {
|
|||||||
|
|
||||||
router.addRoutes(RootController<WebsiteRequestContext>(
|
router.addRoutes(RootController<WebsiteRequestContext>(
|
||||||
assetVersion: assetVersion,
|
assetVersion: assetVersion,
|
||||||
|
siteOrigin: siteOrigin,
|
||||||
analytics: analytics
|
analytics: analytics
|
||||||
).routes)
|
).routes)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ extension Tag {
|
|||||||
@Tag static var controller: Tag
|
@Tag static var controller: Tag
|
||||||
/// Tests exercising an enumeration of the Website library.
|
/// Tests exercising an enumeration of the Website library.
|
||||||
@Tag static var enumeration: Tag
|
@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.
|
/// Tests exercising a page of the Website library.
|
||||||
@Tag static var page: Tag
|
@Tag static var page: Tag
|
||||||
/// Tests exercising a type of the Website library.
|
/// Tests exercising a type of the Website library.
|
||||||
|
|||||||
Reference in New Issue
Block a user