diff --git a/.pa11yci.json b/.pa11yci.json
new file mode 100644
index 0000000..8fb6192
--- /dev/null
+++ b/.pa11yci.json
@@ -0,0 +1,10 @@
+{
+ "defaults": {
+ "standard": "WCAG2AA",
+ "runners": ["axe", "htmlcs"],
+ "timeout": 60000,
+ "chromeLaunchConfig": {
+ "args": ["--no-sandbox", "--disable-dev-shm-usage"]
+ }
+ }
+}
diff --git a/layouts/_markup/render-image.html b/layouts/_markup/render-image.html
new file mode 100644
index 0000000..9c7d95f
--- /dev/null
+++ b/layouts/_markup/render-image.html
@@ -0,0 +1,30 @@
+{{- /*
+Content images. The Markdown title ("") is the photo credit; it is shown
+as a caption instead of a tooltip, so that it is available on touch devices and to screen readers.
+An image on its own line becomes a figure; images sharing a paragraph get an inline credit.
+*/ -}}
+{{- $src := .Destination -}}
+{{- $width := 0 -}}
+{{- $height := 0 -}}
+{{- with or (.PageInner.Resources.Get (strings.TrimPrefix "./" .Destination)) (resources.Get .Destination) -}}
+ {{- $src = .RelPermalink -}}
+ {{- if and (eq .ResourceType "image") (ne .MediaType.SubType "svg") -}}
+ {{- $width = .Width -}}
+ {{- $height = .Height -}}
+ {{- end -}}
+{{- end -}}
+{{- /* A title that only repeats the alt text is not a credit. */ -}}
+{{- $credit := cond (eq .Title .PlainText) "" .Title -}}
+{{- $img := printf `
` (htmlEscape $src) (htmlEscape .PlainText) (cond (gt $width 0) (printf ` width="%d" height="%d"` $width $height) "") -}}
+{{- if .IsBlock -}}
+
+{{ $img | safeHTML }}
+{{- with $credit }}
+{{ . }}
+{{- end }}
+
+{{- else if $credit -}}
+{{ $img | safeHTML }}{{ $credit }}
+{{- else -}}
+{{ $img | safeHTML }}
+{{- end -}}
diff --git a/scripts/pa11y.sh b/scripts/pa11y.sh
new file mode 100755
index 0000000..e8bfece
--- /dev/null
+++ b/scripts/pa11y.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+# Accessibility check with pa11y-ci, used by CI and for local runs (see README.md).
+# Builds the site into a temporary directory, serves it on localhost and checks every page
+# in the sitemaps except the past events, which are kept as they were.
+set -eu
+
+PA11Y_CI_VERSION=5.0.0
+PORT=${PORT:-8080}
+# Pages that are not checked: past events and the individual news posts.
+EXCLUDE='^http://localhost:[0-9]+/[a-z]+/(37c3|38c3|39c3|eh22|posts/[^/]+)/'
+
+# Use an installed Chrome/Chromium instead of letting Puppeteer download one.
+if [ -z "${PUPPETEER_EXECUTABLE_PATH:-}" ]; then
+ for candidate in /usr/bin/chromium-browser /usr/bin/chromium "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; do
+ if [ -x "$candidate" ]; then PUPPETEER_EXECUTABLE_PATH=$candidate; break; fi
+ done
+fi
+export PUPPETEER_EXECUTABLE_PATH
+export PUPPETEER_SKIP_DOWNLOAD=1
+
+cd "$(dirname "$0")/.."
+site=$(mktemp -d)
+hugo --quiet --buildFuture --buildDrafts --baseURL "http://localhost:$PORT/" -d "$site"
+python3 -m http.server "$PORT" --bind 127.0.0.1 -d "$site" >/dev/null 2>&1 &
+server=$!
+trap 'kill $server; rm -rf "$site"' EXIT
+sleep 1
+
+status=0
+for lang in de en; do
+ npx --yes "pa11y-ci@$PA11Y_CI_VERSION" --config .pa11yci.json \
+ --sitemap "http://localhost:$PORT/$lang/sitemap.xml" --sitemap-exclude "$EXCLUDE" || status=1
+done
+exit $status