diff --git a/Makefile b/Makefile
index 6c2118b..00e575d 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,9 @@
-.PHONY: all build test vet fmt tidy run clean skills skill skills-link docs-deps docs-serve docs-build docs-gen docs-gen-check
+.PHONY: all build test vet fmt tidy run clean skills skill skills-link docs-deps docs-serve docs-build docs-pdf docs-gen docs-gen-check
BINARY := katalyst
DOCS_DIR := docs
+DOCS_PDF_EXCLUDE ?=
+DOCS_PDF_TONER_FRIENDLY ?= 0
HUGO_BOOK_MODULE := github.com/alex-shpak/hugo-book
HUGO_LOCAL := $(shell command -v hugo 2>/dev/null)
HUGO_LOCAL_EXTENDED := $(shell hugo version 2>/dev/null | grep -q extended && echo 1 || echo 0)
@@ -83,3 +85,12 @@ docs-serve: docs-deps
docs-build: docs-deps
$(HUGO) -s $(DOCS_DIR) --minify
+
+# Export the whole docs site to PDF. DOCS_PDF_EXCLUDE is a comma-separated list
+# of URL prefixes to omit. DOCS_PDF_TONER_FRIENDLY=1 prints code blocks with a
+# white background instead of the theme's syntax-highlighted dark background.
+# Example:
+# make docs-pdf DOCS_PDF_EXCLUDE=/contributing/,/deep-dives/ DOCS_PDF_TONER_FRIENDLY=1
+docs-pdf: docs-deps
+ HUGO_DOCS_PDF_EXCLUDE="$(DOCS_PDF_EXCLUDE)" HUGO_DOCS_PDF_TONER_FRIENDLY="$(DOCS_PDF_TONER_FRIENDLY)" $(HUGO) -s $(DOCS_DIR) --baseURL / --minify
+ ./scripts/docs-pdf.sh
diff --git a/docs/hugo.yaml b/docs/hugo.yaml
index 05ff510..ff86568 100644
--- a/docs/hugo.yaml
+++ b/docs/hugo.yaml
@@ -4,6 +4,16 @@ title: Katalyst Documentation
theme: github.com/alex-shpak/hugo-book
cleanDestinationDir: true
enableGitInfo: true
+outputFormats:
+ print:
+ mediaType: text/html
+ baseName: print/index
+ isHTML: true
+ notAlternative: true
+outputs:
+ home:
+ - html
+ - print
module:
imports:
- path: github.com/alex-shpak/hugo-book
diff --git a/docs/layouts/index.print.html b/docs/layouts/index.print.html
new file mode 100644
index 0000000..81b7dd8
--- /dev/null
+++ b/docs/layouts/index.print.html
@@ -0,0 +1,195 @@
+
+
+ {{- $exclude := slice -}}
+ {{- with os.Getenv "HUGO_DOCS_PDF_EXCLUDE" -}}
+ {{- $exclude = split . "," -}}
+ {{- end -}}
+ {{- $tonerFriendly := false -}}
+ {{- with os.Getenv "HUGO_DOCS_PDF_TONER_FRIENDLY" -}}
+ {{- $value := lower (trim . " \t\r\n") -}}
+ {{- $tonerFriendly = or (eq $value "1") (eq $value "true") (eq $value "yes") (eq $value "on") -}}
+ {{- end -}}
+
+
+
+ {{ .Site.Title }} PDF
+
+
+
+
+ {{ .Site.Title }}
+ Full documentation export
+ Generated from the Hugo docs source.
+
+
+
+
+
+ {{ partial "docs/print-pages.html" (dict "Pages" .Site.Home.Pages "Exclude" $exclude) }}
+
+
+
diff --git a/docs/layouts/partials/docs/print-excluded.html b/docs/layouts/partials/docs/print-excluded.html
new file mode 100644
index 0000000..3a5ed47
--- /dev/null
+++ b/docs/layouts/partials/docs/print-excluded.html
@@ -0,0 +1,17 @@
+{{- $page := .Page -}}
+{{- $excluded := false -}}
+{{- range .Exclude -}}
+ {{- $rule := trim . " \t\r\n" -}}
+ {{- if $rule -}}
+ {{- if not (hasPrefix $rule "/") -}}
+ {{- $rule = printf "/%s" $rule -}}
+ {{- end -}}
+ {{- if not (hasSuffix $rule "/") -}}
+ {{- $rule = printf "%s/" $rule -}}
+ {{- end -}}
+ {{- if or (eq $page.RelPermalink $rule) (hasPrefix $page.RelPermalink $rule) -}}
+ {{- $excluded = true -}}
+ {{- end -}}
+ {{- end -}}
+{{- end -}}
+{{- return $excluded -}}
diff --git a/docs/layouts/partials/docs/print-pages.html b/docs/layouts/partials/docs/print-pages.html
new file mode 100644
index 0000000..60526c7
--- /dev/null
+++ b/docs/layouts/partials/docs/print-pages.html
@@ -0,0 +1,12 @@
+{{- $exclude := .Exclude -}}
+{{- range .Pages.ByWeight -}}
+ {{- if and (not .Draft) (not (partial "docs/print-excluded.html" (dict "Page" . "Exclude" $exclude))) -}}
+
+ {{ .RelPermalink }}
+ {{ .Content }}
+
+ {{- with .Pages -}}
+ {{ partial "docs/print-pages.html" (dict "Pages" . "Exclude" $exclude) }}
+ {{- end -}}
+ {{- end -}}
+{{- end -}}
diff --git a/docs/layouts/partials/docs/print-toc.html b/docs/layouts/partials/docs/print-toc.html
new file mode 100644
index 0000000..c920aef
--- /dev/null
+++ b/docs/layouts/partials/docs/print-toc.html
@@ -0,0 +1,12 @@
+{{- $depth := .Depth -}}
+{{- $exclude := .Exclude -}}
+{{- range .Pages.ByWeight -}}
+ {{- if and (not .Draft) (not (partial "docs/print-excluded.html" (dict "Page" . "Exclude" $exclude))) -}}
+
+ {{ .Title }}
+
+ {{- with .Pages -}}
+ {{ partial "docs/print-toc.html" (dict "Pages" . "Depth" (add $depth 1) "Exclude" $exclude) }}
+ {{- end -}}
+ {{- end -}}
+{{- end -}}
diff --git a/scripts/docs-pdf.sh b/scripts/docs-pdf.sh
new file mode 100755
index 0000000..4d8ea1a
--- /dev/null
+++ b/scripts/docs-pdf.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+public_dir="${repo_root}/docs/public"
+print_page="${public_dir}/print/index.html"
+output_pdf="${DOCS_PDF_OUTPUT:-${public_dir}/katalyst-docs.pdf}"
+
+if [[ ! -f "${print_page}" ]]; then
+ echo "missing ${print_page}; run make docs-build or make docs-pdf first" >&2
+ exit 1
+fi
+
+find_browser() {
+ local candidates=(
+ "${CHROME_BIN:-}"
+ "google-chrome"
+ "google-chrome-stable"
+ "chromium"
+ "chromium-browser"
+ "microsoft-edge"
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
+ )
+
+ local candidate
+ for candidate in "${candidates[@]}"; do
+ [[ -n "${candidate}" ]] || continue
+ if command -v "${candidate}" >/dev/null 2>&1; then
+ command -v "${candidate}"
+ return 0
+ fi
+ if [[ -x "${candidate}" ]]; then
+ printf '%s\n' "${candidate}"
+ return 0
+ fi
+ done
+}
+
+browser="$(find_browser || true)"
+if [[ -z "${browser}" ]]; then
+ echo "could not find Chrome, Chromium, or Edge; set CHROME_BIN=/path/to/browser" >&2
+ exit 1
+fi
+
+file_size() {
+ stat -f%z "$1" 2>/dev/null || stat -c%s "$1"
+}
+
+port="$(python3 - <<'PY'
+import socket
+
+with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ print(sock.getsockname()[1])
+PY
+)"
+
+python3 -m http.server "${port}" --bind 127.0.0.1 --directory "${public_dir}" >/tmp/katalyst-docs-pdf-http.log 2>&1 &
+server_pid="$!"
+profile_dir="$(mktemp -d)"
+browser_log="$(mktemp)"
+cleanup() {
+ kill "${server_pid}" >/dev/null 2>&1 || true
+ wait "${server_pid}" 2>/dev/null || true
+ rm -rf "${profile_dir}"
+ rm -f "${browser_log}"
+}
+trap cleanup EXIT
+
+for _ in {1..40}; do
+ if python3 - "$port" <<'PY' >/dev/null 2>&1
+import http.client
+import sys
+
+conn = http.client.HTTPConnection("127.0.0.1", int(sys.argv[1]), timeout=0.2)
+conn.request("GET", "/print/")
+response = conn.getresponse()
+sys.exit(0 if response.status < 500 else 1)
+PY
+ then
+ break
+ fi
+ sleep 0.1
+done
+
+mkdir -p "$(dirname "${output_pdf}")"
+rm -f "${output_pdf}"
+
+"${browser}" \
+ --headless=new \
+ --disable-background-networking \
+ --disable-component-update \
+ --disable-default-apps \
+ --disable-gpu \
+ --disable-sync \
+ --metrics-recording-only \
+ --mute-audio \
+ --no-sandbox \
+ --user-data-dir="${profile_dir}" \
+ --print-to-pdf="${output_pdf}" \
+ --no-pdf-header-footer \
+ --print-to-pdf-no-header \
+ "http://127.0.0.1:${port}/print/" \
+ >"${browser_log}" 2>&1 &
+browser_pid="$!"
+
+last_size=0
+stable_count=0
+for _ in {1..240}; do
+ if [[ -f "${output_pdf}" ]]; then
+ size="$(file_size "${output_pdf}")"
+ if [[ "${size}" -gt 0 && "${size}" == "${last_size}" ]]; then
+ stable_count=$((stable_count + 1))
+ else
+ stable_count=0
+ last_size="${size}"
+ fi
+
+ if [[ "${stable_count}" -ge 4 ]]; then
+ break
+ fi
+ fi
+
+ if ! kill -0 "${browser_pid}" >/dev/null 2>&1; then
+ wait "${browser_pid}" || {
+ cat "${browser_log}" >&2
+ exit 1
+ }
+ break
+ fi
+
+ sleep 0.25
+done
+
+if [[ ! -s "${output_pdf}" ]]; then
+ kill "${browser_pid}" >/dev/null 2>&1 || true
+ wait "${browser_pid}" 2>/dev/null || true
+ cat "${browser_log}" >&2
+ echo "browser did not write ${output_pdf}" >&2
+ exit 1
+fi
+
+kill "${browser_pid}" >/dev/null 2>&1 || true
+wait "${browser_pid}" 2>/dev/null || true
+
+echo "wrote ${output_pdf}"