Merged the template branch into main to pick up the 33 upstream changes.
Reconciled the bootstrap-customised files: kept the CCN naming, canonical origin, database slug and analytics comments, dropped the template-only Makefile, README.md and Scripts/bootstrap that bootstrap removes, and took the template's ordering for the security headers in the production compose.
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
# Applies only when this directory is itself the build context. The Dockerfile
|
||||
# builds from the repository root (it copies the sibling Packages/), so today's
|
||||
# builds read the root .dockerignore instead — keep the two in step.
|
||||
|
||||
.build
|
||||
.swiftpm
|
||||
.DS_Store
|
||||
.env.local
|
||||
docker-compose.*
|
||||
Makefile
|
||||
README.md
|
||||
README.md
|
||||
|
||||
# The double star matches at every depth; a bare pattern only covers the context root.
|
||||
**/.DS_Store
|
||||
|
||||
# Local database data directory (bind-mounted by Compose)
|
||||
Tests/DB
|
||||
|
||||
+82
-25
@@ -3,8 +3,8 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin
|
||||
|
||||
## 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`.
|
||||
@@ -38,14 +38,16 @@ The persistence backend runs as a `Fluent` service inside the application's Serv
|
||||
Requests pass through the middleware chain in this order (outermost first), then reach the routes:
|
||||
```
|
||||
LogRequestsMiddleware
|
||||
→ SecurityHeadersMiddleware (security headers on every response)
|
||||
→ VaryMiddleware (marks every response as varying on Accept-Encoding)
|
||||
→ ResponseCompressionMiddleware (gzip/deflate above the size threshold)
|
||||
→ LocalizationMiddleware (negotiates the request's language)
|
||||
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
|
||||
→ FileMiddleware (serves Resources/Static)
|
||||
RootController (GET / → landing page)
|
||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||
→ SecurityHeadersMiddleware (security headers on every response)
|
||||
→ HTTPSRedirectMiddleware (301 to site.origin when forwarded over plain HTTP)
|
||||
→ 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 language: ?lang=, path prefix, then Accept-Language)
|
||||
→ NotFoundMiddleware (renders the localized not-found page on .notFound)
|
||||
→ FileMiddleware (serves Resources/Static)
|
||||
RootController (GET / → landing page; GET /<lang> → its other editions)
|
||||
HealthController (GET /health → liveness, GET /health/ready → readiness)
|
||||
```
|
||||
|
||||
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
|
||||
@@ -53,17 +55,39 @@ 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 favicon / SVG icon / apple-touch-icon / web-manifest links, and the two `theme-color` metas.
|
||||
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.
|
||||
|
||||
`sitemap.xml` is the one part that does *not* follow the catalog: it is a static file, so a new language needs its editions added by hand — `/nl`, `/nl/<page>`, one `<loc>` each, alongside the default language's. Give each the same spelling the page's own canonical carries (the root is the bare origin, with no trailing slash), or the two disagree about which URL is canonical.
|
||||
|
||||
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**:
|
||||
@@ -88,12 +112,14 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
|
||||
### Static file caching
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for fingerprinted assets (CSS, JS) and fonts; also marked `immutable`. The pages reference CSS/JS through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. |
|
||||
| `cache.maxAge.asset` | `CACHE_MAX_AGE_ASSET` | `31536000` (1 year) | `max-age` for the fingerprinted assets (CSS, JS, MP4, JPEG, WebP) and fonts; also marked `immutable`. The pages reference them through content-versioned URLs (`?v=<token>`), so a deploy busts them by changing the URL. Fonts are immutable subset files, preloaded unversioned. |
|
||||
| `cache.maxAge.text` | `CACHE_MAX_AGE_TEXT` | `3600` (1 hour) | `max-age` for the remaining `text/*` assets (e.g. `robots.txt`), which keep unversioned URLs; also marked `must-revalidate`. |
|
||||
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for images (ICO, PNG, SVG). |
|
||||
| `cache.maxAge.image` | `CACHE_MAX_AGE_IMAGE` | `604800` (1 week) | `max-age` for the remaining images — the icons (ICO, PNG, SVG), which a browser fetches unversioned whatever the markup says, so they cannot be `immutable`. |
|
||||
| `cache.maxAge.default` | `CACHE_MAX_AGE_DEFAULT` | `86400` (1 day) | `max-age` for everything else — including `site.webmanifest` (`application/manifest+json`) and `sitemap.xml` (`application/xml`), neither of which is `text/*`. |
|
||||
|
||||
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`) are resolved before the general `text/*` category.
|
||||
The groups are matched in order, so the specific media types (`text/css`, `text/javascript`, `font/*`, `video/mp4`, `image/jpeg`, `image/webp`) resolve before the general `text/*` and `image/*` categories.
|
||||
|
||||
> **An image the markup references without a `?v=` token must be neither JPEG nor WebP**, or it is served immutable for a year and no deploy can dislodge it.
|
||||
|
||||
### Response compression
|
||||
| Config key | Environment variable | Default | Description |
|
||||
@@ -107,6 +133,31 @@ The groups are matched in order, so the specific media types (`text/css`, `text/
|
||||
| `http.port` | `HTTP_PORT` | _none_ | Port the server listens on. Supplied via the `--http-port` CLI flag (the Docker image passes `8080`). |
|
||||
| `http.serverName` | `HTTP_SERVER_NAME` | `CCNWebsite` | Server name and logger label. |
|
||||
|
||||
### HTTPS redirect
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `https.trustForwardedProto` | `HTTPS_TRUST_FORWARDED_PROTO` | `false` | Read the visitor's scheme from the `X-Forwarded-Proto` header and answer the plain-HTTP ones with `301 Moved Permanently` to the same path on `site.origin`. Enable **only** behind a reverse proxy that sets the header — it is the sole trigger. |
|
||||
|
||||
Redirecting collapses the `http://` and `https://` copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details:
|
||||
|
||||
- **`301`, not `302`** — a temporary redirect keeps the HTTP URLs indexed. Browsers cache it for a long time, so settle the target first.
|
||||
- **Target built from `site.origin`, not the `Host` header** — a client cannot steer it. An origin that is not itself HTTPS disables the middleware instead of looping.
|
||||
- **`/.well-known/` is exempt** — redirecting the ACME challenge path breaks certificate renewal.
|
||||
|
||||
`docker-compose.yml` enables it for production; `docker-compose.override.yml` pins it off for local development.
|
||||
|
||||
Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches `/about` and `/about/` alike, so
|
||||
every `GET`/`HEAD` whose path ends in a slash is answered with a `301` to the form without one (`//` collapses to `/`; `/` is left alone). The
|
||||
`Location` is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected `POST` as a
|
||||
`GET` and drop the body.
|
||||
|
||||
### 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 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. 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 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -115,17 +166,19 @@ The groups are matched in order, so the specific media types (`text/css`, `text/
|
||||
### Persistence
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). |
|
||||
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `postgres` (PostgreSQL). Any other value fails the boot. |
|
||||
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
|
||||
| `database.host` | `DATABASE_HOST` | `localhost` | PostgreSQL host. Ignored for `inMemory`. |
|
||||
| `database.port` | `DATABASE_PORT` | `5432` | PostgreSQL port. Ignored for `inMemory`. |
|
||||
| `database.name` | `DATABASE_NAME` | `ccn` | Database name. Ignored for `inMemory`. |
|
||||
| `database.username` | `DATABASE_USERNAME` | `ccn` | Database username. Ignored for `inMemory`. |
|
||||
| `database.password` | `DATABASE_PASSWORD` | _(empty)_ | Database password. Provide via the environment/a secret — never commit it. |
|
||||
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Ignored for `inMemory`. |
|
||||
| `database.tls` | `DATABASE_TLS` | `prefer` | TLS posture when connecting: `off`, `prefer`, or `require`. Any other value fails the boot. Ignored for `inMemory`. |
|
||||
| `database.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
|
||||
| `database.pool.timeout` | `DATABASE_POOL_TIMEOUT` | `10` | Seconds a query waits for a pooled connection before failing. Ignored for `inMemory`. |
|
||||
|
||||
> **Unrecognized tokens fail the boot.** Neither `database.driver` nor `database.tls` falls back, because both fallbacks are silent and costly: an unrecognized driver would run on the ephemeral in-memory database and discard every write on restart, and an unrecognized posture would land on `prefer`, which hands the password over in plaintext when the upgrade is stripped. The thrown `ConfigError` names the tokens the key accepts.
|
||||
|
||||
> **Connection budget:** the pool holds `database.pool.maxPerEventLoop` connections *per event loop*, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default `max_connections` of 100. Size this against the server's limit, not against the number alone. On an exhausted pool, a query waits up to `database.pool.timeout` before failing.
|
||||
|
||||
See [Persistence](#persistence-1) below for the workflow.
|
||||
@@ -138,10 +191,12 @@ See [Persistence](#persistence-1) below for the workflow.
|
||||
### Rate limiting
|
||||
| Config key | Environment variable | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window on the subscribe endpoint; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
|
||||
| `rateLimit.limit` | `RATELIMIT_LIMIT` | `5` | Requests admitted per client per window; the excess is answered with `429 Too Many Requests` and a `Retry-After` header. |
|
||||
| `rateLimit.window` | `RATELIMIT_WINDOW` | `60` | Window length, in seconds, the limit applies to. |
|
||||
| `rateLimit.trustForwardedFor` | `RATELIMIT_TRUST_FORWARDED_FOR` | `false` | Key clients by the first `X-Forwarded-For` entry instead of the connection's address. Enable **only** behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. |
|
||||
|
||||
> **Configured but unapplied.** The template ships no endpoint worth limiting, so `RateLimitMiddleware` is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one.
|
||||
|
||||
### Analytics
|
||||
The template ships analytics **off**: `analytics.websiteID` is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:
|
||||
|
||||
@@ -169,7 +224,7 @@ The tracker's origin is not a configuration key: it is single-sourced in code so
|
||||
| `security.permissionsPolicy` | `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` |
|
||||
| `security.strictTransportSecurity` | `SECURITY_STRICT_TRANSPORT_SECURITY` | _none (omitted)_ |
|
||||
|
||||
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. `docker-compose.yml` enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy.
|
||||
`Strict-Transport-Security` has **no default** and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. `docker-compose.yml` enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy. [`https.trustForwardedProto`](#https-redirect) carries the same caveat: a cached `301` is as sticky as an HSTS commitment.
|
||||
|
||||
## Running locally
|
||||
Directly with Swift:
|
||||
@@ -236,7 +291,7 @@ make pkg-test # = swift test --disable-xctest --enable-code-coverage --enable-
|
||||
|
||||
Tests use the [Swift Testing](https://developer.apple.com/documentation/testing/) framework. `make pkg-test` runs the service package's own two targets: `WebsiteTests` (the executable/integration tests) and `WebsiteLibraryTests` (the library unit tests).
|
||||
|
||||
`Tests/Website.xctestplan` — the plan the `Site.xcodeproj` scheme runs — adds the vendored packages' suites on top of those two: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. From the command line, each of those is run from its own package directory (`swift test` in `Packages/<Name>`).
|
||||
`Tests/Website.xctestplan` — the plan the `CCN.xcodeproj` scheme runs — adds the vendored packages' suites on top of those two: `InfrastructureTests`, `PersistenceTests`, `LocalizationTests`, and `UtilityTests`. From the command line, each of those is run from its own package directory (`swift test` in `Packages/<Name>`).
|
||||
|
||||
The `Persistence` package also has its own suite, run from `Packages/Persistence`. It uses the in-memory backend by default; the PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at a database, so `swift test` stays runnable without one:
|
||||
```sh
|
||||
@@ -250,7 +305,7 @@ The production image is built in release mode with a statically linked Swift run
|
||||
|
||||
`IMAGE_PLATFORM` is the single source of truth for the deployment architecture: `make img-check` and `make img-release` both build for it, and `docker-compose.yml` runs the pulled image with it. Keep it matched to the deployment host — the three have to agree, or a release builds for one architecture and the production Compose file refuses to run it. Local development builds are separate and follow `BUILD_PLATFORM` (see `docker-compose.override.yml`), since they target your machine rather than the deployment.
|
||||
|
||||
The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (`.dockerignore` excludes `**/Tests` for the same reason).
|
||||
The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (the root `.dockerignore` excludes `**/Tests/DB` for the same reason).
|
||||
|
||||
### Base images
|
||||
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
|
||||
@@ -280,7 +335,9 @@ docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### Static assets
|
||||
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from.
|
||||
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root.
|
||||
|
||||
Each extension resolves to its own folder (`css/`, `js/`, `img/`, `font/`, `video/`), which a `StaticFile` overrides with `in:` when it needs one of its own — imagery conventionally sits in a folder per page (`img/index`). `img/` ships empty. Every one of them is a constant of the `StaticFile` structure, which is what the pages derive their URLs from.
|
||||
|
||||
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit:
|
||||
- CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`).
|
||||
@@ -289,7 +346,7 @@ The image build optimizes the files under `Resources/Static` in its `assets` sta
|
||||
|
||||
The PNG and SVG passes walk the tree (`--recursive`), so images added in a subdirectory are optimized without touching the Dockerfile.
|
||||
|
||||
Files keep their names and paths, so the URLs derived from the `StaticFile` enumeration are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
|
||||
Files keep their names and paths, so the URLs derived from the `StaticFile` constants are unaffected. The repository sources stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one — serves the optimized copies. Assets are copied from the `assets` stage *after* the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.
|
||||
|
||||
Preview the optimized output locally (Docker only, writes to the git-ignored `.build/minified`):
|
||||
```sh
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://ccn.rock-n-co.de/</loc>
|
||||
<loc>https://ccn.rock-n-co.de</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
@@ -16,7 +16,8 @@ import WebsiteLibrary
|
||||
/// band (so a shared database is never migrated on boot).
|
||||
/// - Parameter reader: the configuration reader the values are read from.
|
||||
/// - Returns: the configured application, ready to run as a service.
|
||||
/// - Throws: an error when the persistence service cannot be built (e.g. its TLS context fails to build).
|
||||
/// - Throws: a ``ConfigError`` when a `database.*` key holds an unrecognized token, or an error when the persistence service cannot be built
|
||||
/// (e.g. its TLS context fails to build).
|
||||
func application(
|
||||
reader: ConfigReader
|
||||
) async throws -> some ApplicationProtocol {
|
||||
@@ -34,8 +35,9 @@ func application(
|
||||
logger.warning("String Catalog is \(isCatalogMissing ? "missing" : "undecodable"); pages will serve raw localization keys")
|
||||
}
|
||||
|
||||
let driver = try reader.driver
|
||||
let persistence = try Service(
|
||||
driver: reader.driver,
|
||||
driver: driver,
|
||||
logger: logger
|
||||
)
|
||||
let fluent = persistence()
|
||||
@@ -52,8 +54,12 @@ func application(
|
||||
analytics: reader.analytics,
|
||||
cacheControl: reader.cacheControl,
|
||||
compressionMinResponseSize: reader.compressionMinResponseSize,
|
||||
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)
|
||||
),
|
||||
@@ -67,7 +73,7 @@ func application(
|
||||
|
||||
// The in-memory backend is recreated on every launch, so it is migrated on startup. The PostgreSQL backend is
|
||||
// left untouched here: a shared database is migrated out of band to avoid multi-instance races.
|
||||
if case .inMemory = reader.driver {
|
||||
if case .inMemory = driver {
|
||||
app.beforeServerStarts {
|
||||
try await fluent.migrate()
|
||||
}
|
||||
@@ -90,7 +96,7 @@ func migration(
|
||||
logLevel: reader.logLevel
|
||||
)
|
||||
let service = try Service(
|
||||
driver: reader.driver,
|
||||
driver: try reader.driver,
|
||||
logger: logger
|
||||
)
|
||||
|
||||
@@ -135,22 +141,28 @@ private func logger(
|
||||
/// Builds the application's router.
|
||||
///
|
||||
/// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the
|
||||
/// vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses
|
||||
/// 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 error 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 error page, and the served static files.
|
||||
/// responses, the rendered not-found page, and the served static files. The HTTPS redirect sits directly beneath it, so a redirect carries the security
|
||||
/// headers but skips the negotiation, compression, and file lookup it would otherwise pay for. The trailing-slash redirect follows it, ahead of the
|
||||
/// routes and `FileMiddleware` that would otherwise answer both spellings of every path.
|
||||
/// - Parameters:
|
||||
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
|
||||
/// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned.
|
||||
/// - analytics: the analytics tracker both pages embed, or `nil` to omit it.
|
||||
/// - cacheControl: the cache-control directives applied to the served static files.
|
||||
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
|
||||
/// - rateLimit: the rate limit applied to the rate-limited routes.
|
||||
/// - 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.
|
||||
@@ -160,8 +172,10 @@ private func router(
|
||||
analytics: Analytics?,
|
||||
cacheControl: CacheControl,
|
||||
compressionMinResponseSize: Int,
|
||||
httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration,
|
||||
rateLimit: RateLimitMiddleware<AppRequestContext>.Configuration,
|
||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||
siteOrigin: String?,
|
||||
logLevel: Logger.Level,
|
||||
probe: Probe
|
||||
) -> Router<AppRequestContext> {
|
||||
@@ -177,6 +191,10 @@ private func router(
|
||||
SecurityHeadersMiddleware(
|
||||
configuration: securityHeaders
|
||||
)
|
||||
HTTPSRedirectMiddleware(
|
||||
configuration: httpsRedirect
|
||||
)
|
||||
TrailingSlashRedirectMiddleware()
|
||||
VaryMiddleware()
|
||||
ResponseCompressionMiddleware(
|
||||
minimumResponseSizeToCompress: compressionMinResponseSize
|
||||
@@ -195,6 +213,7 @@ private func router(
|
||||
router.addController {
|
||||
RootController<AppRequestContext>(
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics
|
||||
)
|
||||
HealthController<AppRequestContext>(
|
||||
|
||||
@@ -56,10 +56,15 @@ package extension ConfigReader {
|
||||
/// The `Cache-Control` policy applied to static files, grouped by media type.
|
||||
///
|
||||
/// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and
|
||||
/// `cache.maxAge.default` keys. Stylesheets and scripts are referenced through fingerprinted URLs (see `FingerprintAssets`) and
|
||||
/// fonts are immutable subset files, so all three are served long-lived and `immutable` — a deploy busts them by changing the URL, never by
|
||||
/// revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation once stale; images and
|
||||
/// everything else are served public with their max-age alone. The groups match in order, so the specific types precede the `text` category.
|
||||
/// `cache.maxAge.default` keys. Stylesheets, scripts, videos, JPEGs, and WebPs are referenced through fingerprinted URLs (see
|
||||
/// `FingerprintAssets`) and fonts are immutable subset files, so all of them are served long-lived and `immutable` — a deploy busts them by
|
||||
/// changing the URL, never by revalidation. The remaining text files (e.g. `robots.txt`) keep their unversioned URLs and require revalidation
|
||||
/// once stale; the remaining images cannot be `immutable` — the group covers the icons, and a browser fetches `/favicon.ico` unversioned
|
||||
/// whatever the markup says — and everything else is served public with its max-age alone. The groups match in order, so the specific types
|
||||
/// precede the `text` and `image` categories.
|
||||
///
|
||||
/// - Important: an image the markup references without a `?v=` token must be in neither JPEG nor WebP, or it is served immutable for a year and
|
||||
/// a deploy cannot dislodge it.
|
||||
var cacheControl: CacheControl {
|
||||
let maxAgeAsset = int(
|
||||
forKey: .Cache.maxAgeAsset,
|
||||
@@ -82,6 +87,9 @@ package extension ConfigReader {
|
||||
(.textCss, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.textJavascript, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.font, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.videoMp4, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.imageJpeg, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.imageWebp, [.public, .maxAge(maxAgeAsset), .immutable]),
|
||||
(.text, [.public, .maxAge(maxAgeText), .mustRevalidate]),
|
||||
(.image, [.public, .maxAge(maxAgeImage)]),
|
||||
(.init(type: .any), [.public, .maxAge(maxAgeDefault)]),
|
||||
@@ -100,51 +108,70 @@ package extension ConfigReader {
|
||||
///
|
||||
/// When `database.driver` selects PostgreSQL, the connection parameters are assembled from the `database.host`, `database.port`,
|
||||
/// `database.name`, `database.username`, `database.password` (empty when unset), `database.tls`,
|
||||
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any other driver value falls back to the in-memory database.
|
||||
/// `database.pool.maxPerEventLoop`, and `database.pool.timeout` keys. Any token but `inMemory` and `postgres` throws a
|
||||
/// ``ConfigError``: a mistyped driver fails the boot rather than running on the in-memory database and discarding every write on restart.
|
||||
var driver: Driver {
|
||||
switch string(
|
||||
forKey: .Database.driver,
|
||||
default: .Database.driver
|
||||
) {
|
||||
case .Database.driverPostgres:
|
||||
return .postgres(
|
||||
.init(
|
||||
host: string(
|
||||
forKey: .Database.host,
|
||||
default: .Database.host
|
||||
),
|
||||
port: int(
|
||||
forKey: .Database.port,
|
||||
default: .Database.port
|
||||
),
|
||||
name: string(
|
||||
forKey: .Database.name,
|
||||
default: .Database.name
|
||||
),
|
||||
username: string(
|
||||
forKey: .Database.username,
|
||||
default: .Database.username
|
||||
),
|
||||
password: string(
|
||||
forKey: .Database.password,
|
||||
default: ""
|
||||
),
|
||||
tls: tls,
|
||||
maxConnectionsPerEventLoop: int(
|
||||
forKey: .Database.poolMaxPerEventLoop,
|
||||
default: .Database.poolMaxPerEventLoop
|
||||
),
|
||||
poolTimeout: .seconds(int(
|
||||
forKey: .Database.poolTimeout,
|
||||
default: .Database.poolTimeout
|
||||
))
|
||||
get throws {
|
||||
switch string(
|
||||
forKey: .Database.driver,
|
||||
default: .Database.driver
|
||||
) {
|
||||
case .Database.driverInMemory:
|
||||
return .inMemory
|
||||
case .Database.driverPostgres:
|
||||
return .postgres(
|
||||
.init(
|
||||
host: string(
|
||||
forKey: .Database.host,
|
||||
default: .Database.host
|
||||
),
|
||||
port: int(
|
||||
forKey: .Database.port,
|
||||
default: .Database.port
|
||||
),
|
||||
name: string(
|
||||
forKey: .Database.name,
|
||||
default: .Database.name
|
||||
),
|
||||
username: string(
|
||||
forKey: .Database.username,
|
||||
default: .Database.username
|
||||
),
|
||||
password: string(
|
||||
forKey: .Database.password,
|
||||
default: ""
|
||||
),
|
||||
tls: try tls,
|
||||
maxConnectionsPerEventLoop: int(
|
||||
forKey: .Database.poolMaxPerEventLoop,
|
||||
default: .Database.poolMaxPerEventLoop
|
||||
),
|
||||
poolTimeout: .seconds(int(
|
||||
forKey: .Database.poolTimeout,
|
||||
default: .Database.poolTimeout
|
||||
))
|
||||
)
|
||||
)
|
||||
)
|
||||
default:
|
||||
return .inMemory
|
||||
case let token:
|
||||
throw ConfigError.unknownDatabaseDriver(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HTTPS redirect middleware configuration, built from the `https.trustForwardedProto` key and ``siteOrigin``.
|
||||
///
|
||||
/// Off by default, so a deployment without a proxy in front never redirects on a header its clients could have written themselves. The redirects
|
||||
/// point at ``siteOrigin`` — the same value the pages build their canonical URLs from, so the two cannot disagree.
|
||||
var httpsRedirect: HTTPSRedirectMiddleware<AppRequestContext>.Configuration {
|
||||
.init(
|
||||
origin: siteOrigin,
|
||||
trustForwardedProto: bool(
|
||||
forKey: .HTTPS.trustForwardedProto,
|
||||
default: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// The minimum log level the application emits at, read from the `log.level` key.
|
||||
///
|
||||
/// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case.
|
||||
@@ -164,7 +191,7 @@ package extension ConfigReader {
|
||||
)
|
||||
}
|
||||
|
||||
/// The rate limit applied to the subscription endpoint, built from the `rateLimit.*` keys.
|
||||
/// The rate limit built from the `rateLimit.*` keys; the template applies it to no route yet.
|
||||
///
|
||||
/// `rateLimit.limit` requests are admitted per client per `rateLimit.window` seconds. When `rateLimit.trustForwardedFor` is set,
|
||||
/// clients are keyed by the first `X-Forwarded-For` entry — enable it only behind a reverse proxy that sets the header, since clients can forge it
|
||||
@@ -226,6 +253,17 @@ package extension ConfigReader {
|
||||
)
|
||||
}
|
||||
|
||||
/// The public origin the site is served at (scheme and host, no trailing slash), read from the `site.origin` key.
|
||||
///
|
||||
/// The redirects and any absolute links derive from it, so a staging deployment can point it at itself — or leave it unset — without the
|
||||
/// production origin leaking into its markup.
|
||||
var siteOrigin: String {
|
||||
string(
|
||||
forKey: .Site.origin,
|
||||
default: .Site.origin
|
||||
)
|
||||
}
|
||||
|
||||
/// The directory the static files are served from, read from the `path.staticFiles` key.
|
||||
var staticFilesPath: String {
|
||||
string(
|
||||
@@ -242,16 +280,45 @@ private extension ConfigReader {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key: `off` and `require` map to their postures, and any
|
||||
/// other value falls back to `prefer`.
|
||||
/// The TLS posture for the PostgreSQL connection, mapped from the `database.tls` key.
|
||||
///
|
||||
/// Any token but `off`, `prefer`, and `require` throws a ``ConfigError``: a mistyped posture (`required`, say) fails the boot rather than
|
||||
/// falling back to `prefer`, which hands the password over in plaintext when the upgrade is stripped.
|
||||
var tls: TLS {
|
||||
switch string(
|
||||
forKey: .Database.tls,
|
||||
default: .Database.tls
|
||||
) {
|
||||
case .Database.tlsOff: .off
|
||||
case .Database.tlsRequire: .require
|
||||
default: .prefer
|
||||
get throws {
|
||||
switch string(
|
||||
forKey: .Database.tls,
|
||||
default: .Database.tls
|
||||
) {
|
||||
case .Database.tlsOff: .off
|
||||
case .Database.tlsPrefer: .prefer
|
||||
case .Database.tlsRequire: .require
|
||||
case let token: throw ConfigError.unknownDatabaseTLS(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ConfigError
|
||||
|
||||
/// A configuration value the executable refuses to boot with; ``description`` names the tokens the key accepts.
|
||||
package enum ConfigError: Error, Equatable, CustomStringConvertible {
|
||||
|
||||
/// The `database.driver` key holds an unrecognized token.
|
||||
case unknownDatabaseDriver(String)
|
||||
|
||||
/// The `database.tls` key holds an unrecognized token.
|
||||
case unknownDatabaseTLS(String)
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
package var description: String {
|
||||
switch self {
|
||||
case .unknownDatabaseDriver(let token):
|
||||
"Unknown 'database.driver' value '\(token)': use '\(String.Database.driverInMemory)' or '\(String.Database.driverPostgres)'."
|
||||
case .unknownDatabaseTLS(let token):
|
||||
"Unknown 'database.tls' value '\(token)': use '\(String.Database.tlsOff)', '\(String.Database.tlsPrefer)', or '\(String.Database.tlsRequire)'."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/// A rendition an image asset is available at, from the narrowest up to the full-size file.
|
||||
///
|
||||
/// ``StaticFile`` names one file per rendition, and a page's `srcset` offers them all so the browser picks by rendered width.
|
||||
/// The declaration order is the `srcset` order: narrowest first.
|
||||
enum ImageWidth: CaseIterable {
|
||||
/// The small rendition, 480 pixels wide.
|
||||
case small
|
||||
/// The medium rendition, 800 pixels wide.
|
||||
case medium
|
||||
/// The large rendition: the full-size file, 1200 pixels wide.
|
||||
case large
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension ImageWidth {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The rendition's width in pixels: what the file is resampled to, and what its `srcset` descriptor states.
|
||||
var width: Int {
|
||||
switch self {
|
||||
case .small: 480
|
||||
case .medium: 800
|
||||
case .large: 1200
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import Infrastructure
|
||||
|
||||
/// A static file shipped with the website service.
|
||||
///
|
||||
/// Each case identifies a file name stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
|
||||
/// `FileMiddleware` middleware. A name can be available with more than one extension (see ``fileExtensions``), each resolving to its own file.
|
||||
enum StaticFile: Asset, CaseIterable {
|
||||
/// The `apple-touch-icon.png` icon.
|
||||
case appleTouchIcon
|
||||
/// The `favicon.ico` icon.
|
||||
case favicon
|
||||
/// The `icon.svg` icon.
|
||||
case icon
|
||||
/// The `icon-192.png` icon for the web manifest.
|
||||
case icon192
|
||||
/// The `icon-512.png` icon for the web manifest.
|
||||
case icon512
|
||||
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
|
||||
case index
|
||||
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
|
||||
case notFound
|
||||
/// The `robots.txt` crawler directives.
|
||||
case robots
|
||||
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
|
||||
case shared
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
case site
|
||||
/// The `sitemap.xml` crawler sitemap.
|
||||
case sitemap
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The file extensions the file is available with.
|
||||
var fileExtensions: [AssetExtension] {
|
||||
switch self {
|
||||
case .appleTouchIcon,
|
||||
.icon192,
|
||||
.icon512: [.png]
|
||||
case .index,
|
||||
.notFound,
|
||||
.shared: [.css, .js]
|
||||
case .favicon: [.ico]
|
||||
case .icon: [.svg]
|
||||
case .robots: [.txt]
|
||||
case .site: [.webmanifest]
|
||||
case .sitemap: [.xml]
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's name, without extension.
|
||||
var fileName: String {
|
||||
switch self {
|
||||
case .appleTouchIcon: "apple-touch-icon"
|
||||
case .favicon: "favicon"
|
||||
case .icon: "icon"
|
||||
case .icon192: "icon-192"
|
||||
case .icon512: "icon-512"
|
||||
case .index: "index"
|
||||
case .notFound: "not-found"
|
||||
case .robots: "robots"
|
||||
case .shared: "shared"
|
||||
case .site: "site"
|
||||
case .sitemap: "sitemap"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,24 @@ import Localization
|
||||
/// The site-wide defaults shared by every page of the website.
|
||||
extension Page {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The fonts preloaded on every page: one per face the stylesheets actually render.
|
||||
///
|
||||
/// Listed rather than derived from every WOFF2 in ``StaticFile/all``: a subset gated by a `unicode-range` no page reaches would add a
|
||||
/// download that never otherwise happens. Empty until the site ships fonts of its own.
|
||||
static var preloadedFonts: [StaticFile] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -14,9 +32,83 @@ extension Page {
|
||||
?? LanguageList().default
|
||||
}
|
||||
|
||||
/// The icon, manifest, and theme colour metadata shared by every page of the website.
|
||||
@HTMLBuilder
|
||||
/// 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
|
||||
/// font twice.
|
||||
@HTMLBuilder
|
||||
var siteMetadata: some HTML {
|
||||
for file in Self.preloadedFonts {
|
||||
link(
|
||||
.rel("preload"),
|
||||
.href(file.urlPath(for: .woff2)),
|
||||
.as(.font),
|
||||
.custom(
|
||||
name: "type",
|
||||
value: AssetExtension.woff2.contentType
|
||||
),
|
||||
.crossorigin(.anonymous)
|
||||
)
|
||||
}
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href(StaticFile.favicon.urlPath(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,8 +25,7 @@ struct NotFoundPage {
|
||||
/// Creates a not-found page localized to the given locale.
|
||||
/// - 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.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
locale: Locale,
|
||||
@@ -38,9 +37,10 @@ struct NotFoundPage {
|
||||
self.locale = locale
|
||||
self.localize = .init(bundle: .module)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Page
|
||||
// MARK: - Page
|
||||
|
||||
extension NotFoundPage: Page {
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Infrastructure
|
||||
|
||||
/// A static file shipped with the website service.
|
||||
///
|
||||
/// Each constant declares one file stored under the static files root (the `Resources/Static` directory) and served by Hummingbird's
|
||||
/// `FileMiddleware` middleware. A file can be available with more than one extension (see ``fileExtensions``), each resolving to its own file, and
|
||||
/// sits in its extension's own folder unless it names a ``folder`` of its own.
|
||||
struct StaticFile: Asset {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The file extensions the file is available with.
|
||||
let fileExtensions: [AssetExtension]
|
||||
|
||||
/// The file's name, without extension.
|
||||
let fileName: String
|
||||
|
||||
/// The folder holding the file, or `nil` when it sits in its extensions' own folders.
|
||||
///
|
||||
/// Imagery sits in a folder per page — `img/index`, `img/about` — rather than in its extension's own.
|
||||
let folder: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Declares a static file.
|
||||
/// - Parameters:
|
||||
/// - fileName: the file's name, without extension.
|
||||
/// - folder: the folder holding the file, or `nil` (the default) to use each extension's own folder.
|
||||
/// - fileExtensions: the extensions the file is available with, one file each.
|
||||
init(
|
||||
_ fileName: String,
|
||||
in folder: String? = nil,
|
||||
as fileExtensions: AssetExtension...
|
||||
) {
|
||||
self.fileExtensions = fileExtensions
|
||||
self.fileName = fileName
|
||||
self.folder = folder
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// Every file the service ships.
|
||||
///
|
||||
/// Spelled out because Swift cannot enumerate a type's own constants: a file missing here is still served, but goes untested.
|
||||
static let all: [Self] = [
|
||||
.appleTouchIcon,
|
||||
.favicon,
|
||||
.icon,
|
||||
.icon192,
|
||||
.icon512,
|
||||
.index,
|
||||
.notFound,
|
||||
.robots,
|
||||
.shared,
|
||||
.site,
|
||||
.sitemap,
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
extension StaticFile {
|
||||
|
||||
/// The `srcset` value offering every rendition of a responsive image, narrowest first, ending on the full-size file.
|
||||
///
|
||||
/// The renditions are named by the given closure, which conventionally gives the `large` one the bare file name and the narrower ones their
|
||||
/// width — as `srcset` files are usually named:
|
||||
///
|
||||
/// ```swift
|
||||
/// static func portrait(_ width: ImageWidth) -> Self {
|
||||
/// Self(width == .large ? "portrait" : "portrait-\(width.width)", in: "img/about", as: .jpg, .webp)
|
||||
/// }
|
||||
/// ```
|
||||
/// - Parameters:
|
||||
/// - fileExtension: the format the renditions are named in.
|
||||
/// - version: the version token appended to each URL, or `nil` (the default) to leave them unversioned.
|
||||
/// - rendition: the file naming a given width.
|
||||
/// - Returns: the `srcset` value for that format.
|
||||
static func srcSet(
|
||||
for fileExtension: AssetExtension,
|
||||
version: String? = nil,
|
||||
rendition: (ImageWidth) -> Self
|
||||
) -> String {
|
||||
ImageWidth.allCases
|
||||
.map { "\(rendition($0).urlPath(for: fileExtension, version: version)) \($0.width)w" }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
extension StaticFile {
|
||||
/// The `apple-touch-icon.png` icon.
|
||||
static let appleTouchIcon = Self("apple-touch-icon", as: .png)
|
||||
|
||||
/// The `favicon.ico` icon.
|
||||
static let favicon = Self("favicon", as: .ico)
|
||||
|
||||
/// The `icon.svg` icon.
|
||||
static let icon = Self("icon", as: .svg)
|
||||
|
||||
/// The `icon-192.png` icon for the web manifest.
|
||||
static let icon192 = Self("icon-192", as: .png)
|
||||
|
||||
/// The `icon-512.png` icon for the web manifest.
|
||||
static let icon512 = Self("icon-512", as: .png)
|
||||
|
||||
/// The `css/index.css` stylesheet and `js/index.js` script for the landing page.
|
||||
static let index = Self("index", as: .css, .js)
|
||||
|
||||
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
|
||||
static let notFound = Self("not-found", as: .css, .js)
|
||||
|
||||
/// The `robots.txt` crawler directives.
|
||||
static let robots = Self("robots", as: .txt)
|
||||
|
||||
/// The `css/shared.css` stylesheet and `js/shared.js` script shared across pages.
|
||||
static let shared = Self("shared", as: .css, .js)
|
||||
|
||||
/// The `site.webmanifest` web application manifest.
|
||||
static let site = Self("site", as: .webmanifest)
|
||||
|
||||
/// The `sitemap.xml` crawler sitemap.
|
||||
static let sitemap = Self("sitemap", as: .xml)
|
||||
}
|
||||
@@ -116,6 +116,9 @@ private extension HealthController {
|
||||
}
|
||||
|
||||
/// Builds a JSON response carrying the given status and payload.
|
||||
///
|
||||
/// Every response is marked `noindex`: the checks answer `200 OK` to anyone, and `robots.txt` allows the whole site. A `Disallow` rule would
|
||||
/// stop the crawl but not the indexing, and would publish the paths to everyone reading the file.
|
||||
/// - Parameters:
|
||||
/// - status: the HTTP status of the response.
|
||||
/// - payload: the JSON body of the response.
|
||||
@@ -126,7 +129,10 @@ private extension HealthController {
|
||||
) -> Response {
|
||||
Response(
|
||||
status: status,
|
||||
headers: [.contentType: "application/json"],
|
||||
headers: [
|
||||
.contentType: "application/json",
|
||||
.robotsTag: "noindex",
|
||||
],
|
||||
body: .init(byteBuffer: .init(string: payload))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ extension AbsoluteConfigKey {
|
||||
/// The absolute configuration key for the server's name.
|
||||
public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName)
|
||||
}
|
||||
/// A namespace for the HTTPS redirect configuration keys, as absolute keys.
|
||||
public enum HTTPS {
|
||||
/// The absolute configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header.
|
||||
public static let trustForwardedProto: AbsoluteConfigKey = .init(.HTTPS.trustForwardedProto)
|
||||
}
|
||||
/// A namespace for the logging configuration keys, as absolute keys.
|
||||
public enum Log {
|
||||
/// The absolute configuration key for the minimum log level.
|
||||
|
||||
@@ -58,6 +58,12 @@ extension ConfigKey {
|
||||
/// The configuration key for the server's name.
|
||||
public static let serverName: ConfigKey = "http.serverName"
|
||||
}
|
||||
/// A namespace for the HTTPS redirect configuration keys.
|
||||
public enum HTTPS {
|
||||
/// The configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header, redirecting the plain-HTTP
|
||||
/// ones to the site origin (enable only behind a trusted proxy that sets the header).
|
||||
public static let trustForwardedProto: ConfigKey = "https.trustForwardedProto"
|
||||
}
|
||||
/// A namespace for the logging configuration keys.
|
||||
public enum Log {
|
||||
/// The configuration key for the minimum log level.
|
||||
@@ -92,4 +98,9 @@ extension ConfigKey {
|
||||
/// The configuration key for the `Strict-Transport-Security` header value (omitted when unset).
|
||||
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
|
||||
}
|
||||
/// A namespace for the site configuration keys.
|
||||
public enum Site {
|
||||
/// The configuration key for the public origin the site is served at (scheme and host, no trailing slash).
|
||||
public static let origin: ConfigKey = "site.origin"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ public extension NotFoundMiddleware {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a not-found middleware that renders the website's error page, localized to the module's String Catalog languages.
|
||||
/// Creates a not-found middleware that renders the website's not-found page, localized to the module's String Catalog languages.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the error page embeds, or `nil` (the default) to omit it.
|
||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit the tracker script.
|
||||
init(
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
|
||||
@@ -24,7 +24,9 @@ extension String {
|
||||
/// A namespace for the persistence's default configuration values and recognized tokens.
|
||||
public enum Database {
|
||||
/// The default persistence driver: in-memory SQLite, which needs no external infrastructure.
|
||||
public static let driver = "inMemory"
|
||||
public static let driver = driverInMemory
|
||||
/// The driver token selecting the in-memory SQLite backend.
|
||||
public static let driverInMemory = "inMemory"
|
||||
/// The driver token selecting the PostgreSQL backend.
|
||||
public static let driverPostgres = "postgres"
|
||||
/// The default PostgreSQL host.
|
||||
@@ -34,9 +36,11 @@ extension String {
|
||||
/// The default database username.
|
||||
public static let username = "ccn"
|
||||
/// The default TLS posture token.
|
||||
public static let tls = "prefer"
|
||||
public static let tls = tlsPrefer
|
||||
/// The TLS token disabling TLS.
|
||||
public static let tlsOff = "off"
|
||||
/// The TLS token upgrading to TLS only when the server offers it.
|
||||
public static let tlsPrefer = "prefer"
|
||||
/// The TLS token requiring TLS.
|
||||
public static let tlsRequire = "require"
|
||||
}
|
||||
@@ -50,4 +54,13 @@ extension String {
|
||||
/// The website server's name.
|
||||
public static let name = "CCNWebsite"
|
||||
}
|
||||
/// A namespace for the site string constants.
|
||||
public enum Site {
|
||||
/// The default public origin the site is served at (scheme and host, no trailing slash).
|
||||
///
|
||||
/// 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, which `https.trustForwardedProto` must enable besides — a `301` is cached for a long time, so it is never issued
|
||||
/// at a host nobody named.
|
||||
public static let origin = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ struct AppTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
// Stylesheets and scripts are referenced through fingerprinted URLs, so they are served immutable.
|
||||
// Referenced through fingerprinted URLs, or an immutable subset file in the case of a font, so all of these are served immutable.
|
||||
private let immutableExtensions: [AssetExtension] = [
|
||||
.css,
|
||||
.js
|
||||
.jpg,
|
||||
.js,
|
||||
.mp4,
|
||||
.webp,
|
||||
.woff2
|
||||
]
|
||||
|
||||
// Absolute path to the copy of the package's "Resources/Static" folder made into the test bundle
|
||||
@@ -107,7 +111,7 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: StaticFile.allCases)
|
||||
@Test(arguments: StaticFile.all)
|
||||
func `static files to be served`(
|
||||
staticFile file: StaticFile
|
||||
) async throws {
|
||||
|
||||
@@ -61,6 +61,96 @@ struct ConfigReaderPropertiesTests {
|
||||
#expect(reader(values: [.Analytics.websiteID: ""]).analytics == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"shared.css",
|
||||
"shared.js",
|
||||
"milker-400.woff2",
|
||||
"booth.mp4",
|
||||
"portrait.jpg",
|
||||
"portrait.webp"
|
||||
])
|
||||
func `cache control to mark the fingerprinted media immutable`(
|
||||
file: String
|
||||
) throws {
|
||||
let header = try #require(reader().cacheControl.getCacheControlHeader(for: file))
|
||||
|
||||
#expect(header.contains("immutable"))
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
// The icons are exempt: a browser fetches `/favicon.ico` unversioned whatever the markup says, and the manifest names the PNGs.
|
||||
"favicon.ico",
|
||||
"icon-192.png",
|
||||
"icon.svg"
|
||||
])
|
||||
func `cache control to leave the unversioned images mutable`(
|
||||
file: String
|
||||
) throws {
|
||||
let header = try #require(reader().cacheControl.getCacheControlHeader(for: file))
|
||||
|
||||
#expect(!header.contains("immutable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cache control to have the unversioned text revalidate`() throws {
|
||||
let header = try #require(reader().cacheControl.getCacheControlHeader(for: "robots.txt"))
|
||||
|
||||
#expect(header.contains("must-revalidate"))
|
||||
#expect(!header.contains("immutable"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to default to the in-memory database`() throws {
|
||||
guard case .inMemory = try reader().driver else {
|
||||
Issue.record("Expected the in-memory driver when 'database.driver' is unset.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to select postgres when configured`() throws {
|
||||
guard case .postgres = try reader(values: [.Database.driver: "postgres"]).driver else {
|
||||
Issue.record("Expected the postgres driver when 'database.driver' selects it.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to throw on an unknown token`() {
|
||||
// A silent fallback would run the deployment on the ephemeral database and discard every write on restart.
|
||||
#expect(throws: ConfigError.unknownDatabaseDriver("mariadb")) {
|
||||
_ = try reader(values: [.Database.driver: "mariadb"]).driver
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
String.Database.tlsOff,
|
||||
String.Database.tlsPrefer,
|
||||
String.Database.tlsRequire
|
||||
])
|
||||
func `driver to accept a recognized tls token`(token: String) throws {
|
||||
let reader = reader(values: [
|
||||
.Database.driver: .init(stringLiteral: .Database.driverPostgres),
|
||||
.Database.tls: .init(stringLiteral: token)
|
||||
])
|
||||
|
||||
guard case .postgres = try reader.driver else {
|
||||
Issue.record("Expected the postgres driver for the '\(token)' TLS token.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `driver to throw on an unknown tls token`() {
|
||||
// A silent fallback to `prefer` hands the password over in plaintext when the upgrade is stripped.
|
||||
#expect(throws: ConfigError.unknownDatabaseTLS("required")) {
|
||||
_ = try reader(values: [
|
||||
.Database.driver: .init(stringLiteral: .Database.driverPostgres),
|
||||
.Database.tls: "required"
|
||||
]).driver
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"ImageWidth enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct ImageWidthTests {
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
ImageWidth.allCases,
|
||||
[480, 800, 1200]
|
||||
))
|
||||
func `width`(
|
||||
for imageWidth: ImageWidth,
|
||||
expects width: Int
|
||||
) {
|
||||
#expect(imageWidth.width == width)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"StaticFile enumeration",
|
||||
.tags(.enumeration)
|
||||
)
|
||||
struct StaticFileTests {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias File = StaticFile
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileExtensions
|
||||
))
|
||||
func `file extensions`(
|
||||
for file: File,
|
||||
expects extensions: [AssetExtension]
|
||||
) {
|
||||
#expect(file.fileExtensions == extensions)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
File.allCases,
|
||||
Self.fileNames
|
||||
))
|
||||
func `file name`(
|
||||
for file: File,
|
||||
expects fileName: String
|
||||
) {
|
||||
#expect(file.fileName == fileName)
|
||||
}
|
||||
|
||||
// MARK: CaseIterable tests
|
||||
|
||||
@Test
|
||||
func `all cases`() {
|
||||
#expect(File.allCases.count == 11)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFileTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let fileExtensions: [[AssetExtension]] = [
|
||||
[.png],
|
||||
[.ico],
|
||||
[.svg],
|
||||
[.png],
|
||||
[.png],
|
||||
[.css, .js],
|
||||
[.css, .js],
|
||||
[.txt],
|
||||
[.css, .js],
|
||||
[.webmanifest],
|
||||
[.xml]
|
||||
]
|
||||
static let fileNames: [String] = [
|
||||
"apple-touch-icon",
|
||||
"favicon",
|
||||
"icon",
|
||||
"icon-192",
|
||||
"icon-512",
|
||||
"index",
|
||||
"not-found",
|
||||
"robots",
|
||||
"shared",
|
||||
"site",
|
||||
"sitemap"
|
||||
]
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -32,6 +32,39 @@ struct IndexPageTests {
|
||||
#expect(html.contains("/js/index.js"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders no font preloads until fonts are declared`() {
|
||||
// The template ships no fonts, so a preload link here would point at a file the service does not serve.
|
||||
#expect(IndexPage.preloadedFonts.isEmpty)
|
||||
#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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Infrastructure
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"StaticFile type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct StaticFileTests {
|
||||
|
||||
// MARK: Type aliases
|
||||
|
||||
typealias File = StaticFile
|
||||
|
||||
// MARK: Computed tests
|
||||
|
||||
/// The path is asserted rather than the three facts behind it: it is the only form the rest of the service sees.
|
||||
@Test(arguments: Self.paths)
|
||||
func `relative paths`(
|
||||
for file: File,
|
||||
expects paths: [String]
|
||||
) {
|
||||
#expect(file.fileExtensions.map(file.relativePath) == paths)
|
||||
}
|
||||
|
||||
/// A preload URL must match the stylesheet's `@font-face` source exactly, so it carries the extension's folder and no version query.
|
||||
@Test
|
||||
func `unversioned font paths`() {
|
||||
let font = File("a-font-400", as: .woff2)
|
||||
|
||||
#expect(font.relativePath(for: .woff2) == "font/a-font-400.woff2")
|
||||
#expect(font.urlPath(for: .woff2) == "/font/a-font-400.woff2")
|
||||
}
|
||||
|
||||
/// Imagery sits in a folder per page, so a declared folder replaces the extension's own for every extension the file has.
|
||||
@Test
|
||||
func `declared folder paths`() {
|
||||
let portrait = File("portrait", in: "img/about", as: .jpg, .webp)
|
||||
|
||||
#expect(portrait.relativePath(for: .jpg) == "img/about/portrait.jpg")
|
||||
#expect(portrait.relativePath(for: .webp) == "img/about/portrait.webp")
|
||||
}
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test(arguments: zip(
|
||||
[AssetExtension.jpg, .webp],
|
||||
["jpg", "webp"]
|
||||
))
|
||||
func `srcset offers every rendition narrowest first`(
|
||||
for fileExtension: AssetExtension,
|
||||
expects suffix: String
|
||||
) {
|
||||
let srcSet = File.srcSet(for: fileExtension) { width in
|
||||
File(
|
||||
width == .large ? "portrait" : "portrait-\(width.width)",
|
||||
in: "img/about",
|
||||
as: .jpg, .webp
|
||||
)
|
||||
}
|
||||
|
||||
#expect(srcSet == [
|
||||
"/img/about/portrait-480.\(suffix) 480w",
|
||||
"/img/about/portrait-800.\(suffix) 800w",
|
||||
"/img/about/portrait.\(suffix) 1200w"
|
||||
].joined(separator: ", "))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `srcset versions every rendition when given a version`() {
|
||||
let srcSet = File.srcSet(for: .jpg, version: "0123456789abcdef") { _ in
|
||||
File("portrait", in: "img/about", as: .jpg)
|
||||
}
|
||||
|
||||
#expect(srcSet.components(separatedBy: "?v=0123456789abcdef").count - 1 == ImageWidth.allCases.count)
|
||||
}
|
||||
|
||||
// MARK: Constants tests
|
||||
|
||||
@Test
|
||||
func `all files`() {
|
||||
#expect(File.all.count == Self.paths.count)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension StaticFileTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// Every file paired with the path it is served at, one per extension, in ``StaticFile/all`` order.
|
||||
static let paths: [(File, [String])] = [
|
||||
(.appleTouchIcon, ["apple-touch-icon.png"]),
|
||||
(.favicon, ["favicon.ico"]),
|
||||
(.icon, ["icon.svg"]),
|
||||
(.icon192, ["icon-192.png"]),
|
||||
(.icon512, ["icon-512.png"]),
|
||||
(.index, ["css/index.css", "js/index.js"]),
|
||||
(.notFound, ["css/not-found.css", "js/not-found.js"]),
|
||||
(.robots, ["robots.txt"]),
|
||||
(.shared, ["css/shared.css", "js/shared.js"]),
|
||||
(.site, ["site.webmanifest"]),
|
||||
(.sitemap, ["sitemap.xml"]),
|
||||
]
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Infrastructure
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
@@ -29,6 +30,7 @@ struct HealthControllerTests {
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ok"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +56,7 @@ struct HealthControllerTests {
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"ready"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -99,6 +102,7 @@ struct HealthControllerTests {
|
||||
#expect(response.status == .serviceUnavailable)
|
||||
#expect(response.headers[.contentType] == "application/json")
|
||||
#expect(body == #"{"status":"unavailable"}"#)
|
||||
#expect(response.headers[.robotsTag] == "noindex")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -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,10 @@ 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.
|
||||
@Tag static var type: Tag
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ services:
|
||||
dockerfile: Services/Website/Dockerfile
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
HTTPS_TRUST_FORWARDED_PROTO: "false"
|
||||
DATABASE_DRIVER: ${DATABASE_DRIVER:-inMemory}
|
||||
DATABASE_HOST: postgres
|
||||
DATABASE_TLS: ${DATABASE_TLS:-off}
|
||||
|
||||
@@ -19,12 +19,11 @@ services:
|
||||
environment:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-CCNWebsite}
|
||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
||||
# Falls back to the policy the app ships with; set it in `.env` to allow the analytics origin,
|
||||
# which must match `String.Analytics.origin`.
|
||||
SECURITY_CONTENT_SECURITY_POLICY: "${SECURITY_CONTENT_SECURITY_POLICY:-default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'}"
|
||||
# Persistence: a managed PostgreSQL database. Provide the password via the environment or a secret — never
|
||||
# commit it.
|
||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
||||
HTTPS_TRUST_FORWARDED_PROTO: "${HTTPS_TRUST_FORWARDED_PROTO:-true}"
|
||||
DATABASE_DRIVER: ${DATABASE_DRIVER:-postgres}
|
||||
DATABASE_HOST: ${DATABASE_HOST:?DATABASE_HOST is required}
|
||||
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||
|
||||
Reference in New Issue
Block a user