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