#!/bin/bash # Build the static site: the page, benchmark, app modules, service worker, the # manifest and icons, and a _headers file for hosts that read one. The model # and runtime directories are published separately (train/publish.sh) into # the same tree, under models/ and runtime/. # # Usage: site/build.sh OUT_DIR # # Optional env: # RELEASE release description (default: site/release.json): modelId, size, # trained, news, features, older, runtime, source; optional analyticsToken # belongs in a deployment override (see docs/deployment.md) # BASE URL path the site is served under, with leading and trailing # slash (default: /) # MODELS_URL URL prefix of the model directories (default: ${BASE}models/) # RUNTIME_URL URL of the runtime directory (default: ${BASE}runtime/) # SOURCE URL for the page's source link (default: the release's "source", else #) # CF_ANALYTICS_TOKEN optional Cloudflare Web Analytics site token (32 hex # characters); overrides release analyticsToken. Empty disables it. # REMOTE_ENDPOINT optional default remote-host URL (else release remoteEndpoint, # else empty). Local remains the default execution mode. # MODEL_ID, SIZE override the release's model (dev-site.sh uses these) # OUT_DIR is replaced except for models/ and runtime/, which are left alone. # Copying, rendering and validation finish in a sibling staging directory before # existing output is changed; installing the staged entries is not atomic. set -euo pipefail [ $# -eq 1 ] || { sed -n '2,/^set -e/p' "$0" | sed '$d;s/^# \{0,1\}//' >&2; exit 2; } HERE=$(cd "$(dirname "$0")" && pwd); REPO=$(dirname "$HERE") OUT=$(realpath -m "$1") RELEASE=${RELEASE:-$HERE/release.json} BASE=${BASE:-/} case "$BASE" in /*/|/) ;; *) echo "BASE must start and end with /" >&2; exit 2;; esac [ -f "$RELEASE" ] || { echo "no release file: $RELEASE" >&2; exit 2; } # Build the shared rules for the browser, then type-check and bundle before # replacing output. Hash emitted bytes, including the bundled Preact # dependency and the rules module, so a browser never mixes app/worker/rules versions. [ -x "$REPO/node_modules/.bin/tsc" ] || { echo "Run npm ci before building the site" >&2; exit 2; } "$REPO/core/wasm/build.sh" (cd "$REPO" && npm run typecheck) BUNDLE=$(mktemp -d) STAGE= trap 'rm -rf "$BUNDLE"; [ -z "$STAGE" ] || rm -rf "$STAGE"' EXIT trap 'exit 1' HUP INT TERM node "$HERE/bundle.mjs" "$BUNDLE" VER=$(cat "$BUNDLE"/*.js "$BUNDLE"/*.map "$BUNDLE"/*.wasm | sha256sum | cut -c1-12) mkdir -p "$(dirname "$OUT")" STAGE=$(mktemp -d "$(dirname "$OUT")/.$(basename "$OUT").partial.XXXXXX") mkdir -p "$STAGE/app/$VER" "$STAGE/assets" cp "$BUNDLE"/* "$STAGE/app/$VER/" cp "$HERE"/assets/* "$STAGE/assets/" cp "$HERE/sw.js" "$STAGE/sw.js" python3 - "$HERE/index.html" "$STAGE" "$RELEASE" "$BASE" "$VER" "$REPO/test/bench.html" <<'PY' import json, os, re, sys page, out, release, base, ver, bench = sys.argv[1:] r = json.load(open(release)) mid = os.environ.get("MODEL_ID") or r["modelId"] size = int(os.environ.get("SIZE") or r["size"]) models = os.environ.get("MODELS_URL") or base + "models/" runtime = os.environ.get("RUNTIME_URL") or f"{base}runtime/{r['runtime']}" source = os.environ.get("SOURCE") or r.get("source") or "#" analytics_token = os.environ.get("CF_ANALYTICS_TOKEN", r.get("analyticsToken", "")) if not isinstance(analytics_token, str) or (analytics_token and not re.fullmatch(r"[0-9a-fA-F]{32}", analytics_token)): sys.exit("CF_ANALYTICS_TOKEN / analyticsToken must be empty or a 32-character hexadecimal site token") analytics = notice = "" if analytics_token: beacon = json.dumps({"token": analytics_token}) analytics = ("") notice = ('

This site uses Cloudflare Web Analytics ' 'for page visits and performance, without analytics cookies or fingerprinting. ' 'Pet names, conversations, diaries and saves are not included.

') values = { "RUNTIME": runtime, "MODELS": models, "MODEL_ID": mid, "SIZE": str(size), "SIZE_MB": str(round(size / 1e6)), "APP": f"{base}app/{ver}", "ASSETS": f"{base}assets", "TRAINED": r.get("trained", ""), "SOURCE": source, "ANALYTICS": analytics, "ANALYTICS_NOTICE": notice, # JSON values for the config block "NEWS": json.dumps(r.get("news", "")), "FEATURES": json.dumps(r.get("features", [])), "OLDER": json.dumps([o for o in r.get("older", []) if o["id"] != mid]), "REMOTE_ENDPOINT": json.dumps(os.environ.get("REMOTE_ENDPOINT", r.get("remoteEndpoint", ""))).replace("<", "\\u003c"), } def render(path): s = re.sub(r"@([A-Z_]+)@", lambda m: values.get(m.group(1), m.group(0)), open(path).read()) left = re.findall(r"@[A-Z_]+@", s) if left: sys.exit(f"unfilled placeholders in {path}: {sorted(set(left))}") return s s = render(page) json.loads(re.search(r'id="pet-config">(.*?)', s, re.S).group(1)) # the config must parse open(f"{out}/index.html", "w").write(s) open(f"{out}/bench.html", "w").write(render(bench)) # Headers for static hosts that read a _headers file; other servers need the # same rules in their own configuration (docs/deployment.md). open(f"{out}/_headers", "w").write(f"""{base}* Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp {base}app/* Cache-Control: public, max-age=31536000, immutable {base}models/* Cache-Control: public, max-age=31536000, immutable {base}runtime/* Cache-Control: public, max-age=31536000, immutable {base}sw.js Cache-Control: no-cache {base}bench.html Cache-Control: no-cache """) PY # Keep published model/runtime directories, replacing the other entries only # after every staged file has been copied and both pages have rendered. mkdir -p "$OUT" find "$OUT" -mindepth 1 -maxdepth 1 ! -name models ! -name runtime -exec rm -rf {} + mv "$STAGE"/* "$OUT/" echo "built $OUT (app $VER)"