Converted the website service project into a reusable template.

This commit is contained in:
2026-07-11 16:20:33 +02:00
parent a4906f8f26
commit 4a8413359f
12 changed files with 296 additions and 38 deletions
+13
View File
@@ -0,0 +1,13 @@
# Template root Makefile — bootstrap convenience only.
# This file is removed by `Tools/bootstrap` once the template is generated.
.DEFAULT_GOAL := help
.PHONY: bootstrap
bootstrap: ## Generate a concrete website from this template (interactive)
@./Scripts/bootstrap
.PHONY: help
help: ## Show available commands
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
@@ -36,9 +36,9 @@ struct ServiceTests {
.init(
host: "127.0.0.1",
port: 3306,
name: "loud",
username: "loud",
password: "loud",
name: "site",
username: "site",
password: "site",
tls: .off,
maxConnectionsPerEventLoop: 1
)
@@ -126,7 +126,7 @@ private extension ServiceTests {
try await fluent.migrate()
let repository = ExampleRepository(fluent: fluent)
let created = try await repository.create(name: "loud")
let created = try await repository.create(name: "site")
#expect(try await repository.all().contains(created))
@@ -158,9 +158,9 @@ private let mysqlDriver: Persistence.Driver? = {
.init(
host: host,
port: environment["MYSQL_TEST_PORT"].flatMap(Int.init) ?? 3306,
name: environment["MYSQL_TEST_NAME"] ?? "loud",
username: environment["MYSQL_TEST_USERNAME"] ?? "loud",
password: environment["MYSQL_TEST_PASSWORD"] ?? "loud",
name: environment["MYSQL_TEST_NAME"] ?? "site",
username: environment["MYSQL_TEST_USERNAME"] ?? "site",
password: environment["MYSQL_TEST_PASSWORD"] ?? "site",
tls: .off,
maxConnectionsPerEventLoop: 2
)
+86
View File
@@ -0,0 +1,86 @@
# Website Template
This repository is a **template** for the platform's websites. It ships as a
complete, buildable reference site whose identity is the neutral placeholder
**`Site`** / **`site`**. You generate a real site from it in two steps:
generate a fresh repository, then run the bootstrap script to stamp in your
site's name and slug.
## What's in here
| Path | Role |
| --- | --- |
| `Services/Website` | The Hummingbird website service (the engine + placeholder content). |
| `Packages/Localization`, `Packages/Persistence` | Reusable packages, **vendored** into every site — each generated repo owns its own copy. |
| `Site.xcodeproj` | The umbrella Xcode project (renamed to your site by bootstrap). |
| `Scripts/bootstrap` | The generator. Rewrites placeholders, then deletes itself. |
The engine is intentionally copied into each site rather than shared as a
remote dependency: a generated site is fully self-contained and can diverge
freely. Engine fixes are propagated to existing sites by hand (or by
re-generating and porting content).
## Generating a new site
### 1. Create a repository from this template
On Gitea, mark this repository as a template once (repository **Settings →
Template → “Template” checkbox**). New sites are then created with the
**“Use this template”** button, which produces a fresh repository with its own
history — no template commits carried over.
> No Gitea button? Just clone this repo, `rm -rf .git`, and continue — the
> bootstrap script offers to re-initialise git for you.
### 2. Bootstrap
Clone the new repository, then from its root run:
```sh
make bootstrap # or: ./Scripts/bootstrap
```
You'll be asked for:
| Prompt | Example | Rewrites |
| --- | --- | --- |
| **Site display name** (PascalCase) | `Loud` | Server name (`LoudWebsite`), `Loud.xcodeproj`, README title. |
| **Project slug** (lowercase) | `loud-ams` | Container owner, database name/user, Compose project name. |
The script rewrites the placeholder occurrences in place, renames
`Site.xcodeproj`, optionally starts a fresh git history, and then removes
itself along with this file and the root `Makefile`.
### 3. Fill in the content
Bootstrap leaves reminders, but in short:
1. **Copy & localization**`Services/Website/Sources/Library/Catalogs/Localizable.xcstrings`
and the landing / 404 pages under `Services/Website/Sources/Library`.
2. **Static assets**`Services/Website/Resources/Static` (CSS, JS, favicon, icons, manifest).
3. **Secrets** — set a real `DATABASE_PASSWORD` in a git-ignored
`Services/Website/.env` (the committed `.env.local` is an example only).
4. **Domain models** — the `Persistence` package ships a sample
`ExampleRecord` / `ExampleRepository`; replace it when you add real data.
### 4. Build & run
```sh
cd Services/Website
make site-run # watch + rebuild
# or
make site-mount # docker compose up --build
```
## What bootstrap does NOT change
- Organisation (`Röck+Cöde VoF`) and Xcode `DEVELOPMENT_TEAM` — shared across all sites.
- The package architecture, middleware, security headers, and persistence layer.
- The generic `website` image name and `Makefile` command names (`site-run`, `site-mount`, …).
## Maintaining the template itself
Edit and test the reference site directly — it builds and its tests pass with
the `Site` / `site` placeholders in place. Keep the placeholders intact so the
bootstrap script's targeted replacements keep matching; if you introduce a new
site-specific value, add a matching rewrite line in `Scripts/bootstrap`.
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env sh
#
# bootstrap — turn this template into a concrete website.
#
# The template ships as a fully buildable reference site whose identity is the
# neutral placeholder "Site" / "site". This script rewrites those placeholders,
# in place, to the values you provide — then removes itself.
#
# Run once, from the repository root:
#
# ./Tools/bootstrap
#
# It is idempotent only in the sense that it self-destructs: once run, the
# placeholders are gone and the script (and its scaffolding) are deleted.
set -eu
# --- Preconditions ------------------------------------------------------------
if [ ! -d "Services/Website" ]; then
echo "error: run this from the repository root (Services/Website not found)." >&2
exit 1
fi
XCODEPROJ="./Site.xcodeproj"
# --- Prompts ------------------------------------------------------------------
printf 'Site display name (PascalCase, e.g. Berlin) [Site]: '
read -r NAME
NAME="${NAME:-Site}"
DEFAULT_SLUG="$(printf '%s' "$NAME" | tr '[:upper:]' '[:lower:]')"
printf 'Project slug (lowercase, used for owner/db/compose, e.g. loud-berlin) [%s]: ' "$DEFAULT_SLUG"
read -r SLUG
SLUG="${SLUG:-$DEFAULT_SLUG}"
echo
echo " display name : $NAME (server name \"${NAME}Website\", ${NAME}.xcodeproj)"
echo " slug : $SLUG (container owner, database name/user, compose project)"
echo
printf 'Apply these values? [y/N]: '
read -r CONFIRM
case "$CONFIRM" in
y | Y | yes | YES) ;;
*)
echo "Aborted. Nothing was changed."
exit 1
;;
esac
# --- Helpers ------------------------------------------------------------------
# Portable in-place sed (works on both BSD/macOS and GNU/Linux).
rewrite() {
file="$1"
shift
[ -f "$file" ] || return 0
tmp="${file}.bootstrap.tmp"
sed "$@" "$file" >"$tmp" && mv "$tmp" "$file"
}
W="Services/Website"
# --- Rewrites -----------------------------------------------------------------
# Swift constants: server name + database name/username.
rewrite "$W/Sources/Library/Public/Extensions/String+Constants.swift" \
-e "s/SiteWebsite/${NAME}Website/g" \
-e "s/= \"site\"/= \"${SLUG}\"/g"
# Local env example.
rewrite "$W/.env.local" \
-e "s/SiteWebsite/${NAME}Website/g" \
-e "s/^HOST_OWNER=site\$/HOST_OWNER=${SLUG}/" \
-e "s/^DATABASE_NAME=site\$/DATABASE_NAME=${SLUG}/" \
-e "s/^DATABASE_USERNAME=site\$/DATABASE_USERNAME=${SLUG}/" \
-e "s/^DATABASE_PASSWORD=site\$/DATABASE_PASSWORD=${SLUG}/"
# Production compose.
rewrite "$W/docker-compose.yml" \
-e "s/SiteWebsite/${NAME}Website/g" \
-e "s/^name: site-platform\$/name: ${SLUG}-platform/" \
-e "s/DATABASE_NAME:-site}/DATABASE_NAME:-${SLUG}}/" \
-e "s/DATABASE_USERNAME:-site}/DATABASE_USERNAME:-${SLUG}}/"
# Local override compose.
rewrite "$W/docker-compose.override.yml" \
-e "s/HOST_OWNER:-site}/HOST_OWNER:-${SLUG}}/" \
-e "s/DATABASE_NAME:-site}/DATABASE_NAME:-${SLUG}}/" \
-e "s/DATABASE_USERNAME:-site}/DATABASE_USERNAME:-${SLUG}}/" \
-e "s/DATABASE_PASSWORD:-site}/DATABASE_PASSWORD:-${SLUG}}/"
# Makefile db-shell fallbacks.
rewrite "$W/Makefile" \
-e "s/),site)/),${SLUG})/g"
# Service README.
rewrite "$W/README.md" \
-e "s/^# Site Website\$/# ${NAME} Website/" \
-e "s/\*\*Site\*\*/**${NAME}**/" \
-e "s/SiteWebsite/${NAME}Website/g" \
-e "s/\`site\`/\`${SLUG}\`/g"
# Xcode project: PBXProject name + directory.
if [ -d "$XCODEPROJ" ]; then
rewrite "$XCODEPROJ/project.pbxproj" -e "s/\"Site\"/\"${NAME}\"/g"
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git mv "$XCODEPROJ" "./${NAME}.xcodeproj" 2>/dev/null || mv "$XCODEPROJ" "./${NAME}.xcodeproj"
else
mv "$XCODEPROJ" "./${NAME}.xcodeproj"
fi
fi
# --- Optional: fresh git history ---------------------------------------------
echo
printf 'Start a fresh git history (drops the template history)? [y/N]: '
read -r FRESH
case "$FRESH" in
y | Y | yes | YES)
if command -v git >/dev/null 2>&1; then
rm -rf .git
git init -q
echo "Reinitialised git. Remember to add your remote and commit."
fi
;;
*) ;;
esac
# --- Self-cleanup -------------------------------------------------------------
rm -f Scripts/bootstrap
rmdir Scripts 2>/dev/null || true
rm -f Makefile # root convenience Makefile (bootstrap only)
rm -f README.md # template usage docs
rm -rf .gitea # Gitea template metadata
# --- Done ---------------------------------------------------------------------
cat <<EOF
Done. "${NAME}" (slug "${SLUG}") is ready.
Next steps:
1. Replace the placeholder content:
- $W/Sources/Library/Catalogs/Localizable.xcstrings (copy)
- $W/Sources/Library (landing / 404 pages)
- $W/Resources/Static (css, js, favicon, icons)
2. Set a real database password in a git-ignored $W/.env
(the committed .env.local defaults the password to the slug — do NOT ship that).
3. Point the git remote at your new repository:
git remote set-url origin <new-repo-url> # or 'git remote add origin ...'
4. Build and run:
cd $W && make site-run
The Persistence package still ships an ExampleRecord / ExampleRepository sample
model — replace it with your real domain models when you add persistence.
EOF
+5 -5
View File
@@ -12,7 +12,7 @@
HOST_CONTAINER=containers.rock-n-code.com
# Owner of the container running the Website service.
HOST_OWNER=loud
HOST_OWNER=site
# Password for authenticating to the container registry.
HOST_PASSWORD=
@@ -36,7 +36,7 @@ IMAGE_TAG=latest
# --- Application config -------------------------------------------------------
# Server name reported by the HTTP service.
HTTP_SERVER_NAME=LoudWebsite
HTTP_SERVER_NAME=SiteWebsite
# Log verbosity: trace | debug | info | notice | warning | error | critical
LOG_LEVEL=info
@@ -55,13 +55,13 @@ DATABASE_HOST=localhost
DATABASE_PORT=3306
# Name of the database to connect to.
DATABASE_NAME=loud-ams
DATABASE_NAME=site
# Username of the database to connect as.
DATABASE_USERNAME=loud-ams
DATABASE_USERNAME=site
# Provide the real password via the environment or a secret — never commit it.
DATABASE_PASSWORD=loud-ams
DATABASE_PASSWORD=site
# TLS posture when connecting: off | prefer | require (use `require` in production).
DATABASE_TLS=off
+3 -3
View File
@@ -90,9 +90,9 @@ db-shell: ## Open a SQL shell on the local database instance
--profile database \
exec mariadb \
mariadb \
--user=$(or $(DATABASE_USERNAME),loud) \
--password=$(or $(DATABASE_PASSWORD),loud) \
$(or $(DATABASE_NAME),loud)
--user=$(or $(DATABASE_USERNAME),site) \
--password=$(or $(DATABASE_PASSWORD),site) \
$(or $(DATABASE_NAME),site)
.PHONY: db-unmount
db-unmount: ## Stop and remove the local database instance (keeps the data volume)
+6 -6
View File
@@ -1,5 +1,5 @@
# Loud Website
The **Loud** 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.
# 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:
@@ -73,7 +73,7 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
| --- | --- | --- | --- |
| `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` | `LoudWebsite` | Server name and logger label. |
| `http.serverName` | `HTTP_SERVER_NAME` | `SiteWebsite` | Server name and logger label. |
### Logging
| Config key | Environment variable | Default | Description |
@@ -87,8 +87,8 @@ A dotted config key maps to an environment variable by upper-casing, splitting c
| `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` | `loud` | Database name. Ignored for `inMemory`. |
| `database.username` | `DATABASE_USERNAME` | `loud` | Database username. 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`. |
@@ -203,7 +203,7 @@ The Makefile and Compose files read these from a `.env` file (or the environment
| `IMAGE_PLATFORM` | Build platform (e.g. `linux/amd64`). |
| `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 `LoudWebsite`). |
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
| `SECURITY_STRICT_TRANSPORT_SECURITY` | HSTS header value (default `max-age=31536000; includeSubDomains`). |
| `DATABASE_DRIVER` | `inMemory` (default) or `mysql`. Set to `mysql` in production to use a managed database. |
| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | MySQL/MariaDB connection (when `DATABASE_DRIVER=mysql`). Provide the password via a secret. |
@@ -8,9 +8,9 @@ extension String {
/// The default MySQL/MariaDB host.
public static let host = "localhost"
/// The default database name.
public static let name = "loud"
public static let name = "site"
/// The default database username.
public static let username = "loud"
public static let username = "site"
/// The default TLS posture token.
public static let tls = "prefer"
/// The TLS token disabling TLS.
@@ -47,6 +47,6 @@ extension String {
/// A namespace for the server string constants.
public enum Server {
/// The website server's name.
public static let name = "LoudWebsite"
public static let name = "SiteWebsite"
}
}
+4 -4
View File
@@ -26,7 +26,7 @@ services:
# docker compose --profile database up mariadb
mariadb:
image: mariadb:11
container_name: ${HOST_OWNER:-loud}-db
container_name: ${HOST_OWNER:-site}-db
restart: unless-stopped
profiles:
- database
@@ -34,9 +34,9 @@ services:
- "127.0.0.1:${DATABASE_PORT:-3306}:3306"
environment:
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
MARIADB_DATABASE: ${DATABASE_NAME:-loud-ams}
MARIADB_USER: ${DATABASE_USERNAME:-loud-ams}
MARIADB_PASSWORD: ${DATABASE_PASSWORD:-loud-ams}
MARIADB_DATABASE: ${DATABASE_NAME:-site}
MARIADB_USER: ${DATABASE_USERNAME:-site}
MARIADB_PASSWORD: ${DATABASE_PASSWORD:-site}
MARIADB_AUTO_UPGRADE: "1"
command:
- "--character-set-server=utf8mb4"
+4 -4
View File
@@ -1,4 +1,4 @@
name: loud-platform
name: site-platform
# Production base configuration.
# Deploys a pre-built image pulled from a registry — no build step.
@@ -19,7 +19,7 @@ services:
- "${HOST_PORT:-8080}:8080"
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-SiteWebsite}
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
# Persistence: in-memory by default; set DATABASE_DRIVER=mysql to run against a
# managed MySQL/MariaDB database. Provide the password via the environment or a
@@ -27,7 +27,7 @@ services:
DATABASE_DRIVER: ${DATABASE_DRIVER:-mysql}
DATABASE_HOST: ${DATABASE_HOST:-localhost}
DATABASE_PORT: ${DATABASE_PORT:-3306}
DATABASE_NAME: ${DATABASE_NAME:-loud-ams}
DATABASE_USERNAME: ${DATABASE_USERNAME:-loud-ams}
DATABASE_NAME: ${DATABASE_NAME:-site}
DATABASE_USERNAME: ${DATABASE_USERNAME:-site}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-}
DATABASE_TLS: ${DATABASE_TLS:-require}
@@ -44,7 +44,7 @@
LastUpgradeCheck = 2700;
ORGANIZATIONNAME = "Röck+Cöde VoF";
};
buildConfigurationList = 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "Loud" */;
buildConfigurationList = 02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "Site" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@@ -62,14 +62,14 @@
/* End PBXProject section */
/* Begin XCBuildConfiguration section */
02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "Loud" */ = {
02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "Site" */ = {
isa = XCBuildConfiguration;
buildSettings = {
DEVELOPMENT_TEAM = 7FMNM89WKG;
};
name = Debug;
};
02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "Loud" */ = {
02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "Site" */ = {
isa = XCBuildConfiguration;
buildSettings = {
DEVELOPMENT_TEAM = 7FMNM89WKG;
@@ -79,11 +79,11 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "Loud" */ = {
02642FD42FEEB9A5007FA466 /* Build configuration list for PBXProject "Site" */ = {
isa = XCConfigurationList;
buildConfigurations = (
02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "Loud" */,
02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "Loud" */,
02642FD52FEEB9A5007FA466 /* Debug configuration for PBXProject "Site" */,
02642FD62FEEB9A5007FA466 /* Release configuration for PBXProject "Site" */,
);
defaultConfigurationName = Release;
};