313 lines
26 KiB
Markdown
313 lines
26 KiB
Markdown
# Site Website
|
||
The **Site** public website service — a [Hummingbird](https://github.com/hummingbird-project/hummingbird) server that renders a static landing page and serves the site's static assets.
|
||
|
||
## 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.
|
||
- 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, and the JSON-LD structured-data script (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`.
|
||
- Serves static files (CSS, JS, icons, manifest, `robots.txt`, `sitemap.xml`) from `Resources/Static` via Hummingbird's `FileMiddleware`, tagged with media-type-specific `Cache-Control`; the production image ships minified copies (see [Static assets](#static-assets)).
|
||
- Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
|
||
- Compresses responses (gzip/deflate) above a configurable size when the client advertises support.
|
||
- Stamps a hardened set of security headers on every response.
|
||
- Persists data through [Fluent](https://github.com/hummingbird-project/hummingbird-fluent), against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a MySQL/MariaDB server, selected by a single configuration key.
|
||
|
||
## Requirements
|
||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||
- Docker (optional) for the containerized run/deploy workflow.
|
||
- The [Hummingbird](https://github.com/hummingbird-project/hummingbird) CLI (`hb`) — optional, only for `make site-run` (watch and rebuild on change).
|
||
|
||
## Architecture
|
||
Two SwiftPM targets:
|
||
| Target | Kind | Path | Role |
|
||
| --- | --- | --- | --- |
|
||
| `Website` | executable | `Sources/App` | Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode. |
|
||
| `WebsiteLibrary` | library | `Sources/Library` | Controllers, the pages (`IndexPage`, `NotFoundPage`) and their shared `Page` defaults, the `StaticFile` asset catalog, the request context, the String Catalog, and the `*+Defaults` extensions and configuration-key constants that supply the site's specifics to `Infrastructure`. |
|
||
|
||
The `Website` executable depends on four local packages:
|
||
- `Localization` (`Packages/Localization`) — the `Localize` and `Negotiate` helpers and the `LanguageList` of catalog languages (used by `WebsiteLibrary`).
|
||
- `Infrastructure` (`Packages/Infrastructure`) — the shared Hummingbird toolkit: the `RouterController` protocol and `addController` result-builder extension for declarative routing, the security/vary/rate-limit/localization/not-found middlewares, the `Page` and `Asset` scaffolding, the `SocialCard` and `StructuredData` head-metadata types, the pre-rendered localized HTML responses, and the `FingerprintAssets` version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata) through the `*+Defaults` extensions in `WebsiteLibrary`.
|
||
- `Persistence` (`Packages/Persistence`) — the Fluent-based data layer: the `Driver` selector, the `Service` factory that builds the `Fluent` service, the `PrepareDB` registrar that declares the migrations, and the `Probe` consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on `swift-configuration`; the executable maps the `database.*` keys onto the driver.
|
||
- `Utility` (`Packages/Utility`) — small shared helpers with no server dependencies, currently the `NormalizeEmail` method.
|
||
|
||
The persistence backend runs as a `Fluent` service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).
|
||
|
||
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)
|
||
```
|
||
|
||
The router is created with `.autoGenerateHeadEndpoints`, so each of those `GET` routes gets a `HEAD` sibling for free.
|
||
|
||
### 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 `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags.
|
||
|
||
Four of those are 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):
|
||
| 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. |
|
||
|
||
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.
|
||
|
||
## Configuration
|
||
Configuration is read through [swift-configuration](https://github.com/apple/swift-configuration) from
|
||
the following sources, **highest precedence first**:
|
||
1. Command-line arguments (e.g. `--http-host 0.0.0.0`)
|
||
2. Process environment variables
|
||
3. A `.env.local` file in the working directory (optional)
|
||
4. A `.env` file in the working directory (optional)
|
||
5. Built-in defaults
|
||
|
||
The two files play different roles:
|
||
- **`.env`** (git-ignored) holds your deployment values — it is the file the Makefile and Compose read for the `${VAR}` placeholders, and typically selects the MySQL/MariaDB backend.
|
||
- **`.env.local`** (tracked) holds the local development values — the in-memory database and `debug` logging, plus the image/deployment placeholders the Makefile falls back to when no `.env` exists. Because it sits *above* `.env`, a direct launch (`swift run` or a debugger) runs against the local values even when `.env` points at a deployment, the same way `docker-compose.override.yml` overrides the base Compose file. Compose itself never reads it, and the production image does not ship it — only the executable, its resources, and the static files are staged into the final stage.
|
||
|
||
### Environment variable naming
|
||
A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with `_`. For example `http.serverName` → `HTTP_SERVER_NAME`,
|
||
`security.strictTransportSecurity` → `SECURITY_STRICT_TRANSPORT_SECURITY`, `cache.maxAge.text` → `CACHE_MAX_AGE_TEXT`.
|
||
> To disable a header or override a value, leave the variable **unset** to fall back to the default. A
|
||
> variable that is set but **blank** is treated as an explicit empty value, not as "use the default".
|
||
|
||
### 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.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.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.
|
||
|
||
### Response compression
|
||
| Config key | Environment variable | Default | Description |
|
||
| --- | --- | --- | --- |
|
||
| `compression.minimumResponseSize` | `COMPRESSION_MINIMUM_RESPONSE_SIZE` | `1024` | Minimum response body size, in bytes, before compression is applied. |
|
||
|
||
### HTTP server
|
||
| Config key | Environment variable | Default | Description |
|
||
| --- | --- | --- | --- |
|
||
| `http.host` | `HTTP_HOST` | _none_ | Host the server binds to. Supplied via the `--http-host` CLI flag (the Docker image passes `0.0.0.0`). |
|
||
| `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` | `SiteWebsite` | Server name and logger label. |
|
||
|
||
### Logging
|
||
| Config key | Environment variable | Default | Description |
|
||
| --- | --- | --- | --- |
|
||
| `log.level` | `LOG_LEVEL` | `info` | Minimum log level. |
|
||
|
||
### Persistence
|
||
| Config key | Environment variable | Default | Description |
|
||
| --- | --- | --- | --- |
|
||
| `database.driver` | `DATABASE_DRIVER` | `inMemory` | Backend: `inMemory` (ephemeral SQLite, no infrastructure) or `mysql` (MySQL/MariaDB). |
|
||
| `database.migrate` | `DATABASE_MIGRATE` (flag `--database-migrate`) | `false` | When set, run the migrations and exit instead of serving. |
|
||
| `database.host` | `DATABASE_HOST` | `localhost` | MySQL/MariaDB host. Ignored for `inMemory`. |
|
||
| `database.port` | `DATABASE_PORT` | `3306` | MySQL/MariaDB port. Ignored for `inMemory`. |
|
||
| `database.name` | `DATABASE_NAME` | `site` | Database name. Ignored for `inMemory`. |
|
||
| `database.username` | `DATABASE_USERNAME` | `site` | 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.pool.maxPerEventLoop` | `DATABASE_POOL_MAX_PER_EVENT_LOOP` | `4` | Maximum pooled connections per event loop. Ignored for `inMemory`. |
|
||
|
||
See [Persistence](#persistence-1) below for the workflow.
|
||
|
||
### Paths
|
||
| Config key | Environment variable | Default | Description |
|
||
| --- | --- | --- | --- |
|
||
| `path.staticFiles` | `PATH_STATIC_FILES` | `Resources/Static` | Directory, relative to the working directory, that static files are served from. |
|
||
|
||
### Rate limiting
|
||
These keys configure the `RateLimitMiddleware` budget for the upcoming newsletter subscription endpoint. They are read at startup, but the middleware is **not yet attached to any route** — the values have no effect until the subscription endpoint ships.
|
||
| 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.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 — otherwise clients can forge it; leave it off when the server is directly reachable. |
|
||
|
||
### Security headers
|
||
| Config key | Environment variable | Default |
|
||
| --- | --- | --- |
|
||
| `security.contentSecurityPolicy` | `SECURITY_CONTENT_SECURITY_POLICY` | `default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'` |
|
||
| `security.contentTypeOptions` | `SECURITY_CONTENT_TYPE_OPTIONS` | `nosniff` |
|
||
| `security.frameOptions` | `SECURITY_FRAME_OPTIONS` | `DENY` |
|
||
| `security.referrerPolicy` | `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` |
|
||
| `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: it only takes effect over HTTPS (browsers ignore it on plain HTTP) and is "sticky" in browsers, so it must stay off in local HTTP development. It is enabled for production in `docker-compose.yml`, where it only has an effect once traffic is served over HTTPS behind a TLS-terminating proxy.
|
||
|
||
## Running locally
|
||
Directly with Swift:
|
||
```sh
|
||
swift run Website # binds to Hummingbird's default 127.0.0.1:8080
|
||
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug
|
||
```
|
||
|
||
A direct run picks up the local development overrides from `.env.local` (in-memory database, `debug` logging) over whatever `.env` configures. To run against another backend, override per launch — e.g. `DATABASE_DRIVER=mysql swift run Website` — since process environment variables outrank both files.
|
||
|
||
Or via the Makefile / Docker (uses `docker-compose.override.yml`, which builds from source and sets `LOG_LEVEL=debug`):
|
||
```sh
|
||
make pkg-build # swift build
|
||
make pkg-outdated # list the SPM dependencies that can be updated
|
||
make pkg-update # update the SPM dependencies
|
||
make site-run # run locally with hot reload (hb watch)
|
||
make site-mount # docker compose up --build --detach
|
||
make site-unmount # docker compose down + remove the local image
|
||
```
|
||
|
||
`make help` lists every available target.
|
||
|
||
## Persistence
|
||
The service persists data through Fluent and selects its backend at runtime with `database.driver`.
|
||
|
||
### In-memory (default)
|
||
With no configuration, the service uses an ephemeral in-memory SQLite database. It is created and **migrated on startup** every launch, so `swift run Website` and `docker compose up` work with no external database — ideal for local development and tests.
|
||
|
||
### MySQL / MariaDB
|
||
Set `DATABASE_DRIVER=mysql` and the connection values (`DATABASE_HOST`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, …). Unlike the in-memory backend, a MySQL/MariaDB database is **not** migrated on boot — a shared database is migrated out of band so multiple instances never race:
|
||
```sh
|
||
# Run the registered migrations against the configured database, then exit.
|
||
swift run Website --database-migrate
|
||
# In a container (production), against the managed database:
|
||
docker compose -f docker-compose.yml run --rm website --database-migrate
|
||
```
|
||
|
||
A local MariaDB for development lives behind the `database` Compose profile (so a plain `docker compose up` still runs in-memory). Its data directory is bind-mounted to `Tests/DB` (git-ignored), so the database survives `db-unmount` and container restarts:
|
||
```sh
|
||
make db-mount # start MariaDB (docker compose --profile database up --wait mariadb)
|
||
make db-migrate # run migrations against it
|
||
make db-shell # open a SQL shell on it
|
||
make db-unmount # stop and remove the container (keeps the data volume)
|
||
make db-reset # stop and remove the container, and delete its data volume
|
||
DATABASE_DRIVER=mysql make site-mount # run the site against MariaDB
|
||
```
|
||
> **Note:** because the data lives in the bind-mounted `Tests/DB` folder rather than a named volume, `db-reset`'s `--volumes` flag does **not** clear it. To start from an empty database, delete `Tests/DB` by hand.
|
||
|
||
### Health checks
|
||
`GET /health` is a liveness check (process is up, no dependency check). `GET /health/ready` is a readiness check that runs `SELECT 1` against the database and returns `200` when reachable or `503` otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure.
|
||
|
||
`docker-compose.yml` configures the `website` container healthcheck against `GET /health`, so Compose reports process liveness without coupling container health to database reachability.
|
||
|
||
## Testing
|
||
```sh
|
||
make pkg-test
|
||
# = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel
|
||
```
|
||
|
||
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>`).
|
||
|
||
The `Persistence` package has its own suite (run it from `Packages/Persistence`). Its tests run against the in-memory backend by default; the MySQL integration test is skipped unless a database is pointed at via `MYSQL_TEST_HOST` (with optional `MYSQL_TEST_PORT`/`NAME`/`USERNAME`/`PASSWORD`), so `swift test` stays runnable with no database:
|
||
```sh
|
||
cd ../../Packages/Persistence && swift test # in-memory only
|
||
# With the local MariaDB up (make db-mount):
|
||
cd ../../Packages/Persistence && MYSQL_TEST_HOST=127.0.0.1 swift test
|
||
```
|
||
|
||
## Deployment
|
||
The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`. `make img-check` pins the build to `linux/amd64`; `make img-release` builds for `IMAGE_PLATFORM`.
|
||
|
||
The executable is the `ENTRYPOINT` and its serving flags are the `CMD`:
|
||
```dockerfile
|
||
ENTRYPOINT ["./Website"]
|
||
CMD ["--http-host", "0.0.0.0", "--http-port", "8080"]
|
||
```
|
||
The split is what makes the migrate-and-exit invocation below work: `docker compose run --rm website --database-migrate` replaces the `CMD` flags without having to override the entrypoint.
|
||
|
||
Verify the image builds for its `linux/amd64` target without tagging or publishing:
|
||
```sh
|
||
make img-check
|
||
```
|
||
|
||
Build, tag, and push a release to the registry (an explicit version is required):
|
||
```sh
|
||
make img-release version=1.2.3
|
||
```
|
||
|
||
Pull and run the prebuilt image in production — the `-f docker-compose.yml` flag is important, as it skips the local-development override:
|
||
```sh
|
||
docker compose -f docker-compose.yml pull
|
||
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.
|
||
|
||
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions 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/`).
|
||
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng), recursively — the output is pixel-identical, only encoded smaller.
|
||
- SVGs are minified with [svgo](https://github.com/svg/svgo), recursively.
|
||
|
||
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 sources in the repository stay readable and unminified: a direct `swift run` serves them as-is, while any image build — including the local `make site-mount` one, which builds the same Dockerfile — serves the optimized copies.
|
||
|
||
The Dockerfile copies only package manifests and Swift source inputs into the release build stage. Static assets are copied from the separate `assets` stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release binary build cache.
|
||
|
||
Preview the optimized output locally — requires only Docker and writes to the git-ignored `.build/minified`:
|
||
```sh
|
||
make ast-minify
|
||
```
|
||
|
||
#### Crawler files
|
||
`robots.txt` and `sitemap.xml` need an absolute origin, which the template ships as the placeholder `https://site.example.com` — an [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) reserved domain, so an un-bootstrapped copy can never point a crawler at a real site. The bootstrap script prompts for the canonical site URL and rewrites both files with it; it warns if the placeholder is left in place.
|
||
|
||
To change the origin later, edit the `Sitemap:` line in `robots.txt` and the `<loc>` entries in `sitemap.xml`. Add a `<loc>` per public page as the site grows — nothing generates the sitemap at runtime.
|
||
|
||
> **Still manual:** `site.webmanifest` ships empty `name` / `short_name` fields; bootstrap does not fill them in.
|
||
|
||
### Icons
|
||
All icons are renditions of the star mark in `icon.svg`, which is the canonical source — there is no external design file to regenerate from.
|
||
|
||
| File | Size | Used by | Dark mode |
|
||
| --- | --- | --- | --- |
|
||
| `icon.svg` | vector | Tab icon in modern browsers (preferred over the ICO) | Adapts: an embedded `prefers-color-scheme` style flips the accent paths from `#000` to `#f3ecf5`. |
|
||
| `favicon.ico` | 32×32 | Tab icon in browsers without SVG favicon support (Safari) | Theme-neutral **by design**: it is the orange star *without* the accent paths, so one raster reads on both themes. Keep it accent-free when regenerating. |
|
||
| `icon-192.png`, `icon-512.png` | 192/512 | `site.webmanifest` install icons and splash screens | None (no platform mechanism); full artwork, light rendering, on a transparent background. |
|
||
| `apple-touch-icon.png` | 180×180 | iOS home-screen bookmarks | None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black. |
|
||
|
||
The icon and manifest links come from the shared `Page+Defaults` extension, so every page carries them, alongside two `theme-color` metas: `#fafafa` unqualified, then `#0c0710` qualified with `(prefers-color-scheme: dark)`.
|
||
|
||
> **Note:** a browser applies the *first* `theme-color` whose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first in `Page+Defaults` if the dark value should take effect.
|
||
|
||
The raster icons are committed binaries, regenerated from `icon.svg` on demand via a throwaway container (no local toolchain needed) — e.g. for the 512px rendition:
|
||
```sh
|
||
docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
|
||
apk add --no-cache imagemagick librsvg oxipng &&
|
||
magick -background none -density 256 /work/icon.svg -depth 8 PNG32:/work/icon-512.png &&
|
||
oxipng --opt max --strip safe /work/icon-512.png'
|
||
```
|
||
(`-density` scales the 192px viewBox: `96 × target ÷ 192`. For `favicon.ico`, rasterize a star-only copy of the SVG at 32px and pack it with `icotool -c --raw`.)
|
||
|
||
### Required variables
|
||
The Makefile and Compose files read these from a `.env` file (or the environment). Provide your own values — do **not** commit secrets.
|
||
| Variable | Used for |
|
||
| --- | --- |
|
||
| `HOST_CONTAINER` | Container registry host (e.g. `registry.example.com`). |
|
||
| `HOST_OWNER` | Registry namespace / owner. |
|
||
| `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. |
|
||
| `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. `IMAGE_TAG` is the fallback for `make img-release`'s `version=` argument. |
|
||
| `IMAGE_PLATFORM` | Build platform for `make img-release` (e.g. `linux/amd64`). `make img-check` pins `linux/amd64` regardless. |
|
||
| `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). |
|
||
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
||
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
|
||
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
|
||
| `DATABASE_DRIVER` | `inMemory` or `mysql`. The production Compose file defaults it to `mysql`; the local override defaults back to the in-memory backend. |
|
||
| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
|
||
| `DATABASE_TLS` | TLS posture when connecting: `off`, `prefer`, or `require` (default `prefer` in production — set `require` when the database enforces TLS, so a stripped connection fails instead of silently downgrading to plaintext). |
|
||
|
||
Run the migrations against the production database once before (or during) rollout: `docker compose -f docker-compose.yml run --rm website --database-migrate`.
|