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 + + + + + + + +
+ {{ 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))) -}} + + {{- 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}"