Document Jerboa site publishing contract

ober

a9d33e697e894f986d94a5d5b29d1636f3f82208

diff --git a/README.md b/README.md
index 19e6da8..1e73773 100644
--- a/README.md
+++ b/README.md
@@ -281,6 +281,8 @@ Start with these docs:
 - [`docs/build.md`](docs/build.md), [`docs/cross-compile.md`](docs/cross-compile.md),
   [`docs/single-binary.md`](docs/single-binary.md), and
   [`docs/jpkg-guide.md`](docs/jpkg-guide.md) for builds and packages.
+- [`docs/site-publishing.md`](docs/site-publishing.md) for keeping
+  `jerboa.sh` synchronized with this repository.
 
 ## License
 
diff --git a/docs/site-publishing.md b/docs/site-publishing.md
new file mode 100644
index 0000000..c01a247
--- /dev/null
+++ b/docs/site-publishing.md
@@ -0,0 +1,84 @@
+# Jerboa Site Publishing
+
+`jerboa.sh` presents Jerboa, but the source of truth for language and
+runtime facts lives in this repository. The website repository owns
+layout, style, the deployable web binary, and production-specific glue.
+
+## Ownership
+
+Jerboa owns:
+
+- canonical documentation under `docs/`
+- runnable examples under `examples/`
+- machine-readable feature, API, changelog, and security data under `data/`
+- public brand assets under `assets/`
+
+`jerboa-site` owns:
+
+- page composition and visual design
+- site-specific copy that is not canonical language documentation
+- generated snapshots imported from this repository
+- the web binary and deployment workflow
+
+This split keeps documentation close to the code that changes while
+letting the website stay small and private.
+
+## Metadata Export
+
+Use `tools/emit-site-data` to produce a pinned metadata snapshot for a
+site sync:
+
+```sh
+tools/emit-site-data --out-dir dist/site-data
+```
+
+The command writes `jerboa-source.json` from the committed `HEAD` tree.
+The JSON records:
+
+- repository path, remote, branch, commit, and `VERSION`
+- whether the local worktree had uncommitted changes when exported
+- UTC generation time
+- checksums and byte sizes for top-level `docs/*.md`
+- checksums and byte sizes for top-level `data/*`
+- checksums and byte sizes for top-level `examples/*`
+- checksums and byte sizes for top-level `assets/*`
+
+The site should store this generated file as
+`data/jerboa-source.json` and treat its `repo.commit` value as the
+exact Jerboa revision represented by the website.
+
+## Sync Workflow
+
+Normal local update flow:
+
+```sh
+cd ~/mine/jerboa
+git status --short
+tools/emit-site-data --out-dir dist/site-data
+
+cd ~/mine/jerboa-site
+tools/sync-jerboa --jerboa ~/mine/jerboa --write
+git diff
+make binary
+```
+
+Commit Jerboa documentation updates in this repository with the code or
+library behavior that made them necessary. Commit the `jerboa-site`
+generated snapshot separately, after reviewing the site diff.
+
+## LLM-Assisted Updates
+
+An LLM may draft summaries for homepage highlights, "what changed" copy,
+or migration notes. It must not be the source of truth.
+
+Required guardrails:
+
+- feed the LLM only the emitted metadata, selected Jerboa docs, and a
+  bounded `git diff` or commit range
+- require source paths or commit ids in generated notes
+- review the rendered diff before committing
+- do not let the LLM deploy or edit production state
+- never replace extracted API/module facts with prose guesses
+
+The reliable path is: Jerboa emits facts, the site imports a pinned
+snapshot, and optional LLM text is reviewed as ordinary documentation.
diff --git a/tools/emit-site-data b/tools/emit-site-data
new file mode 100755
index 0000000..f681e52
--- /dev/null
+++ b/tools/emit-site-data
@@ -0,0 +1,163 @@
+#!/bin/sh
+set -eu
+
+usage() {
+  cat <<'USAGE'
+usage: tools/emit-site-data [--out-dir DIR]
+
+Emit the Jerboa metadata snapshot consumed by jerboa-site.
+
+Without --out-dir the JSON is written to stdout. With --out-dir, the
+snapshot is written to DIR/jerboa-source.json.
+USAGE
+}
+
+out_dir=
+
+while [ "$#" -gt 0 ]; do
+  case "$1" in
+    --out-dir)
+      shift
+      if [ "$#" -eq 0 ]; then
+        echo "emit-site-data: --out-dir requires a directory" >&2
+        exit 2
+      fi
+      out_dir=$1
+      ;;
+    -h|--help)
+      usage
+      exit 0
+      ;;
+    *)
+      echo "emit-site-data: unknown argument: $1" >&2
+      usage >&2
+      exit 2
+      ;;
+  esac
+  shift
+done
+
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
+
+json_escape() {
+  printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
+}
+
+json_string() {
+  printf '"%s"' "$(json_escape "$1")"
+}
+
+git_value() {
+  git -C "$repo_root" "$@" 2>/dev/null || printf 'unknown'
+}
+
+stdin_sha256() {
+  if command -v sha256sum >/dev/null 2>&1; then
+    sha256sum | awk '{print $1}'
+  else
+    shasum -a 256 | awk '{print $1}'
+  fi
+}
+
+blob_sha256() {
+  git -C "$repo_root" show "HEAD:$1" | stdin_sha256
+}
+
+blob_bytes() {
+  git -C "$repo_root" cat-file -s "HEAD:$1"
+}
+
+is_dirty() {
+  if ! git -C "$repo_root" diff --quiet -- .; then
+    return 0
+  fi
+  if ! git -C "$repo_root" diff --cached --quiet -- .; then
+    return 0
+  fi
+  return 1
+}
+
+emit_git_file_list() {
+  rel_dir=$1
+  pattern=$2
+
+  first=1
+  git -C "$repo_root" ls-tree -r --name-only HEAD -- "$rel_dir" | sort | while IFS= read -r rel_path; do
+    rest=${rel_path#"$rel_dir/"}
+    case "$rest" in
+      */*) continue ;;
+    esac
+    base=${rel_path##*/}
+    case "$base" in
+      $pattern) ;;
+      *) continue ;;
+    esac
+    if [ "$first" -eq 1 ]; then
+      first=0
+    else
+      printf ',\n'
+    fi
+    printf '      {"path": '
+    json_string "$rel_path"
+    printf ', "sha256": '
+    json_string "$(blob_sha256 "$rel_path")"
+    printf ', "bytes": %s}' "$(blob_bytes "$rel_path")"
+  done
+}
+
+emit_json() {
+  remote=$(git_value config --get remote.origin.url)
+  branch=$(git_value rev-parse --abbrev-ref HEAD)
+  commit=$(git_value rev-parse HEAD)
+  version=unknown
+  if [ -f "$repo_root/VERSION" ]; then
+    version=$(sed -n '1p' "$repo_root/VERSION")
+  fi
+  generated_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
+  working_tree_dirty=false
+  if is_dirty; then
+    working_tree_dirty=true
+  fi
+
+  printf '{\n'
+  printf '  "schema": "jerboa.site-source.v1",\n'
+  printf '  "repo": {\n'
+  printf '    "path": '; json_string "$repo_root"; printf ',\n'
+  printf '    "remote": '; json_string "$remote"; printf ',\n'
+  printf '    "branch": '; json_string "$branch"; printf ',\n'
+  printf '    "commit": '; json_string "$commit"; printf ',\n'
+  printf '    "version": '; json_string "$version"; printf ',\n'
+  printf '    "source_tree": "HEAD",\n'
+  printf '    "working_tree_dirty": %s\n' "$working_tree_dirty"
+  printf '  },\n'
+  printf '  "generated_at": '; json_string "$generated_at"; printf ',\n'
+  printf '  "site_contract": {\n'
+  printf '    "human_docs": "docs/",\n'
+  printf '    "machine_data": "data/",\n'
+  printf '    "examples": "examples/",\n'
+  printf '    "assets": "assets/"\n'
+  printf '  },\n'
+  printf '  "canonical_sources": {\n'
+  printf '    "docs": [\n'
+  emit_git_file_list docs '*.md'
+  printf '\n    ],\n'
+  printf '    "data": [\n'
+  emit_git_file_list data '*'
+  printf '\n    ],\n'
+  printf '    "examples": [\n'
+  emit_git_file_list examples '*'
+  printf '\n    ],\n'
+  printf '    "assets": [\n'
+  emit_git_file_list assets '*'
+  printf '\n    ]\n'
+  printf '  }\n'
+  printf '}\n'
+}
+
+if [ -n "$out_dir" ]; then
+  mkdir -p "$out_dir"
+  emit_json > "$out_dir/jerboa-source.json"
+else
+  emit_json
+fi