Project updates from Template (#1)

This PR contains the latest updates from the generic Website template, which have been added while working on #loud-amsterdam.

Reviewed-on: #1
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-09-04 13:40:35 +00:00
committed by javier
parent 08b4d80064
commit 65b62681eb
60 changed files with 2347 additions and 326 deletions
+11 -2
View File
@@ -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
+4
View File
@@ -39,6 +39,10 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get install -y libjemalloc-dev \
&& rm -rf /var/lib/apt/lists/*
# Pin git to HTTP/1.1 for the dependency clones. GitHub answers the git-upload-pack POST with a 401 when it is sent
# over HTTP/2 by the git 2.43 that Noble ships, which SPM reports as a clone failure asking for a username.
RUN git config --global http.version HTTP/1.1
# Set up a build area
WORKDIR /build
+85 -26
View File
@@ -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,41 @@ 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. |
| `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. |
| `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, alternate locales, 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:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). 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.
The same set belongs in the site-wide structured data: `StructuredData`'s `inLanguage` declares it on the `WebSite` node, so a crawler reads the site's languages from the graph as well as from the alternates. A page's social card says the same to a scraper: its `locale` is the edition the page *is*, and `SocialCard`'s `alternateLocales` names the rest, each mapped through `ogLocales` the way `ogLocale` maps the page's.
`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 +114,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 +135,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 +168,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 +193,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 +226,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 +293,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 +307,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 +337,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 +348,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"
}
}
@@ -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 = "https://ccn.rock-n-co.de"
}
}
+7 -3
View File
@@ -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}
+2 -3
View File
@@ -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}