diff --git a/.env.example b/.env.example index ffec8c0..70b4db6 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,21 @@ FILES_SECRET_KEY= # server-side; players never see it. openssl rand -hex 24. FILES_LOCAL_SECRET= +# ── generators.${BASE_DOMAIN} — offline Minecraft data pack generators ──── +# A pinned fork of misode.github.io, built from source and served with its own +# data mirror so it works with ZERO internet at the LAN party. The app is 100% +# client-side, so the mirror is what makes it offline-capable — not the fact +# that the host has no WAN. Full design: plan/21-generators.md. + +# Absolute host path to the ulicraft-generators checkout (the fork). This is the +# docker build context; the fork carries its own Dockerfile + Caddyfile. +GENERATORS_REPO=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-generators + +# Where tooling/sync-generators-data.sh puts the ~107M mcmeta + vanilla-mcdoc +# mirror. Gitignored, fetched deliberately with WAN, mounted read-only. +# Must be re-synced (with a matching MC_REF) if the pinned MC version moves. +GENERATORS_DATA_DIR=./generators-data + # Only needed if using CurseForge-exclusive mods: # CF_API_KEY=your-curseforge-api-key @@ -102,7 +117,7 @@ FILES_LOCAL_SECRET= # Only needed to issue real TLS certs. DNS-01 needs no inbound ports. LE_EMAIL=you@example.com # space-separated subdomains; apex ${BASE_DOMAIN} is always included -LE_SUBDOMAINS=auth distribution www avatar status files +LE_SUBDOMAINS=auth distribution www avatar status files generators # OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/ # OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it. OVH_END_POINT=ovh-eu diff --git a/.gitignore b/.gitignore index f1a66d9..dfde706 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ server-mods/ # vendored binaries launcher/ +# offline data mirror for generators.${BASE_DOMAIN} (~107M of mcmeta + +# vanilla-mcdoc), synced by tooling/sync-generators-data.sh. Needs WAN, so it is +# fetched deliberately before the party — never at the venue. +generators-data/ + # landing page: generated build output + deps www/ landing/node_modules/ diff --git a/caddy/conf.d/70-generators.caddy b/caddy/conf.d/70-generators.caddy new file mode 100644 index 0000000..7210bdb --- /dev/null +++ b/caddy/conf.d/70-generators.caddy @@ -0,0 +1,36 @@ +# Generators — a pinned, self-contained fork of misode.github.io (Minecraft +# data pack generators) at generators.${BASE_DOMAIN}. Full design: plan/21-generators.md. +# +# The container serves BOTH the app bundle and the ~107M offline data mirror +# (mcmeta + vanilla-mcdoc) that tooling/sync-generators-data.sh produces, so +# the whole thing is single-origin by construction. +# +# WHY THAT MATTERS: this app is 100% client-side. Every data fetch comes from +# the PLAYER'S BROWSER, not from this host. Blocking the container's egress +# proves nothing, and "cochi has no WAN at the party" protects nothing — a +# single leftover upstream URL simply fails in the browser, at the venue, with +# no internet available to debug it. +http://generators.{$BASE_DOMAIN} { + # The CSP is the ENFORCEMENT mechanism for offline operation, not decoration. + # + # `connect-src 'self'` is the load-bearing directive: it hard-blocks any + # fetch/XHR to an origin we do not serve. If an upstream URL survives the + # fork patch — or a future upstream merge reintroduces one — it breaks + # LOUDLY in prod, on an ordinary day with internet available to fix it, + # instead of silently at the party. This is the same lesson as the filestash + # `http://https://` incident: a 200 on `/` proves the server is up, never + # that the app works. + # + # script-src/style-src stay permissive ON PURPOSE for now. Spyglass and + # deepslate may need wasm/eval and this has NOT been characterised yet; + # tightening them is a post-party task. Loosening `connect-src` is not — + # that one is the whole point. + header { + Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; worker-src 'self' blob:; base-uri 'self'; form-action 'self'" + # The mirror is pinned to one MC version and changes only when + # sync-generators-data.sh is re-run, so let browsers keep it. This is + # also what makes a second visit cheap on a LAN with no upstream. + ?Cache-Control "public, max-age=604800" + } + reverse_proxy generators:80 +} diff --git a/docker-compose.yml b/docker-compose.yml index c529fb0..e35a79a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -160,6 +160,7 @@ services: - ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro - ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro - ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro + - ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro - ./www:/srv/www:ro - ./launcher:/srv/launcher:ro # Static site from ANOTHER repo (absolute host path in .env). @@ -170,6 +171,36 @@ services: aliases: - "auth.${BASE_DOMAIN}" + # Minecraft data pack generators at generators.${BASE_DOMAIN} — a pinned fork + # of misode.github.io, built from source. caddy proxies generators. -> :80. + # Full design + the offline rationale: plan/21-generators.md. + # + # Built from the FORK's own checkout (GENERATORS_REPO), not from ./docker/, + # because the fork is not published yet — unlike nmsr, its Dockerfile cannot + # git-clone a pinned upstream ref. The fork carries its own Dockerfile+Caddyfile. + # + # The container serves the app AND the data mirror on one origin. That is the + # entire offline strategy: this app is 100% client-side, so every fetch comes + # from the player's browser. At the party there is no internet — anything + # off-origin is simply dead, which is why caddy/conf.d/70-generators.caddy + # enforces `connect-src 'self'`. + generators: + build: + context: ${GENERATORS_REPO:?GENERATORS_REPO unset in .env (path to the ulicraft-generators checkout)} + args: + # Must match the mcmeta ref mirrored into GENERATORS_DATA_DIR and the + # MIRRORED_VERSION pin in the fork's src/app/services/Versions.ts. + LATEST_VERSION: "1.21.1" + image: ulicraft/generators:local + container_name: generators + restart: unless-stopped + volumes: + # ~107M mcmeta + vanilla-mcdoc mirror from tooling/sync-generators-data.sh. + # Read-only: it is a fetched artifact, never something the app writes. + - ${GENERATORS_DATA_DIR:-./generators-data}:/srv/data:ro + networks: + - mcnet + # Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls # player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080. nmsr: diff --git a/nginx/ulicraft-caddy.conf.tmpl b/nginx/ulicraft-caddy.conf.tmpl index 21eb94d..e01b2c3 100644 --- a/nginx/ulicraft-caddy.conf.tmpl +++ b/nginx/ulicraft-caddy.conf.tmpl @@ -34,7 +34,7 @@ limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m; server { listen 80; listen [::]:80; - server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${BASE_DOMAIN}; + server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${BASE_DOMAIN} generators.${BASE_DOMAIN}; return 301 https://$host$request_uri; } @@ -111,6 +111,29 @@ server { } } +# Data pack generators — a pinned, self-contained fork of misode.github.io. +# Serves its own mcmeta/vanilla-mcdoc mirror from the same origin, so it works +# with ZERO internet at party time (the app is 100% client-side: every data +# fetch comes from the player's browser, not from this host). caddy sets the +# CSP that enforces that — see caddy/conf.d/70-generators.caddy. +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name generators.${BASE_DOMAIN}; + + ssl_certificate ${APP_DIR}/certs/generators.${BASE_DOMAIN}/cert.pem; + ssl_certificate_key ${APP_DIR}/certs/generators.${BASE_DOMAIN}/key.pem; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + location / { + proxy_pass http://ulicraft_caddy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + server { listen 443 ssl http2; listen [::]:443 ssl http2; diff --git a/plan/19-routes.md b/plan/19-routes.md index 1c3cfb4..d0b6f3c 100644 --- a/plan/19-routes.md +++ b/plan/19-routes.md @@ -14,6 +14,7 @@ | `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` | | `files.${BASE_DOMAIN}` | `60-files.caddy` | `filestash:8334` | | `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) | +| `generators.${BASE_DOMAIN}` | `70-generators.caddy` | `generators:80` (offline misode fork + data mirror) | --- diff --git a/plan/21-generators.md b/plan/21-generators.md new file mode 100644 index 0000000..35dd3cf --- /dev/null +++ b/plan/21-generators.md @@ -0,0 +1,168 @@ +# 21 — Generators (`generators.${BASE_DOMAIN}`) + +> Minecraft data pack generators — a pinned, offline-only fork of +> [misode.github.io](https://github.com/misode/misode.github.io), served with +> its own data mirror so it works with **zero internet** at the LAN party. + +## Why self-host at all + +Upstream is public, free and CDN-hosted, so a plain mirror would buy nothing. +Three things justify this: + +1. **Offline.** At the party the machine is present but the network is not. +2. **Version pin.** The generators can never drift ahead of a 1.21.1 server. +3. **Modded schemas.** `public/mcdoc/` is the extension point for mods (see + *C-lite / C-full* below). + +## The one fact that drives every decision + +**The app is 100% client-side.** Every data fetch comes from the *player's +browser*, not from cochi. So: + +- "cochi has no WAN at the party" protects nothing — a leftover upstream URL + just fails in the browser, at the venue, with no internet to debug it. +- Blocking the container's egress proves nothing either. +- The **only** things that make it offline-capable are (a) which origins the + bundle refers to, and (b) the mirror we serve them from. + +That is why the CSP is enforcement, not decoration — see *Verification*. + +## Architecture + +``` +host nginx (TLS, LE cert for generators.) ─► caddy ─► generators:80 + │ (caddy in-container) + caddy/conf.d/70-generators.caddy ── sets Content-Security-Policy + │ + generators container: /srv/app ← built Preact/Vite bundle (from the fork) + /srv/data ← ~109M mirror, bind-mounted :ro +``` + +One origin serves both the app and its data. No CORS, no second cert, no second +DNS name. + +| Piece | Where | +|---|---| +| Fork | `git.fosil.eu/oier/ulicraft-generators`, branch `ulicraft`, `GENERATORS_REPO` in `.env` | +| Dockerfile + Caddyfile | **in the fork** (it is unpublished upstream, so unlike `nmsr` it cannot clone a pinned ref) | +| Mirror sync | `tooling/sync-generators-data.sh` → `GENERATORS_DATA_DIR` (default `./generators-data`, gitignored) | +| Stack vhost | `caddy/conf.d/70-generators.caddy` | +| TLS vhost | `nginx/ulicraft-caddy.conf.tmpl` | + +## The mirror (~109M) + +mcmeta publishes per-version snapshots as **tags** (~4000 of them), not branches. + +| Source | Ref | Size | +|---|---|---| +| `misode/mcmeta` | tag `1.21.1-summary` | 31M | +| `misode/mcmeta` | tag `1.21.1-data` | 34M | +| `misode/mcmeta` | tag `1.21.1-assets-tiny` → served **as** `1.21.1-assets` | 36M | +| `misode/mcmeta` | tag `1.21.1-atlas` | 3.5M | +| `misode/mcmeta` | branch `summary`, `versions/data.min.json` only | 172K | +| `SpyglassMC/vanilla-mcdoc` | branch `generated`, `symbols.json` | 2.4M | + +Three non-obvious points: + +- **`assets-tiny` is assets minus the `.ogg` sounds** (hundreds of MB). It is + served at the `-assets` path because that is what the app requests. Sound + *playback* would 404, which is why the fork deletes the sounds generator. +- **`summary/versions/data.min.json` comes from the branch head, not a tag** + (`DataFetcher.ts:205`), and it runs during Spyglass init. Miss it and the app + dead-ends offline while looking perfectly healthy online. +- The **directory layout mirrors upstream's URL shape** (`/mcmeta/-/…`). + That is what keeps the fork patch to a couple of constants. + +The sync script verifies a **sentinel file per source** rather than trusting a +clean `git clone` exit — a renamed upstream path would otherwise "sync +successfully" and die in the browser at the party. + +## Fork diff (pinned at upstream `2bfe5522`) + +| Change | File | +|---|---| +| `mcmetaUrl` → `/mcmeta`, `vanillaMcdocUrl` → `/vanilla-mcdoc` | `services/DataFetcher.ts` | +| Removed jsdelivr fallback | `services/DataFetcher.ts` | +| Removed hardcoded immersive_weathering raw.githubusercontent URL | `services/DataFetcher.ts` | +| Version locked to `1.21` | `services/Versions.ts` (`MIRRORED_VERSION`) | +| Switcher filters the **view**, not the data | `components/VersionSwitcher.tsx` | +| Routes `/sounds`, `/changelog`, `/versions`, `/whats-new` deleted | `App.tsx`, `pages/Home.tsx` | +| Share disabled (write API, unmirrorable) | `services/Sharing.ts` | +| gtag → no-op, EthicalAds/Giscus/contributors removed, footer + `SOURCE_REPO_URL` | `index.html`, `Home.tsx`, `Footer.tsx`, `Utils.ts` | + +### Version lock — the trap + +`checkVersion()` compares versions **by index** (`config.versions.findIndex`). +Deleting entries from `config.versions` / `VersionIds` makes a removed +`minVersion` resolve to `-1`, so `-1 <= version` passes and generators that do +**not** exist in 1.21 silently render. **Filter the view, never the data.** + +### Upstream policy: pin and freeze + +Do not chase `master`. Upstream ships 26.x while this server sits at 1.21.1, and +every merge risks reintroducing an external origin. Merges are a rare, +deliberate chore — and the CSP is what catches a regression. + +## C-lite vs C-full (modded generators) + +**Currently C-lite.** `public/mcdoc/create.mcdoc` and `neoforge.mcdoc` ship +upstream and cover the pack's two biggest datapack surfaces. `.mcdoc` files are +fetched **same-origin** from `/mcdoc/.mcdoc`, so adding a mod = drop a file +in `public/mcdoc/` + add entries to `src/config.json`. + +**C-full** (autocomplete for the pack's own items/blocks) additionally needs: + +- a registry dump from the live server — KubeJS is already in the pack and + already mounted, so a startup script can dump every registry; +- a patch to `fetchRegistries`, which currently **hardcodes the `minecraft:` + namespace** (`data[id].map(e => 'minecraft:' + e)`), so modded ids cannot + enter autocomplete from data alone; +- modded item icons would still be missing (the atlas is vanilla-only). + +mcdoc is hand-written per mod (upstream's range: 19–1389 lines), so C-full needs +a **named shortlist**, never "the pack". + +## Deploy + +Prerequisites on cochi, in this order — **the first one can take the whole stack +down if skipped**: + +1. **`GENERATORS_REPO` must be set in cochi's `.env`.** compose uses + `${GENERATORS_REPO:?…}`, and an unset required variable makes + `docker compose up` fail to parse the file — **every service, including + Minecraft**, not just this one. +2. Clone the fork on cochi at that path (branch `ulicraft`). +3. `tooling/sync-generators-data.sh` on cochi (~109M, **needs WAN**). +4. `generators` in `LE_SUBDOMAINS` → `tooling/issue-letsencrypt.sh`. +5. **Only then** `tooling/render-nginx.sh --install` (nginx fails its config + test if the cert file does not exist). +6. DNS `A generators.${BASE_DOMAIN}` — and the **party DNS** too. + +Then the normal deploy (`deploy` skill): git pull + `docker compose up -d --build`. + +## Verification + +**A 200 on `/` proves the server is up, not that the app works.** This is an SPA +whose failure mode is a healthy-looking shell over dead data. + +- `curl /mcmeta/1.21.1-summary/registries/data.min.json` — proves the mirror is + actually mounted. An Uptime Kuma monitor should probe **this**, not `/`. +- **Offline acceptance test**: a real browser with every non-origin request + blocked, walking the generators you intend to use. `tooling/`-adjacent harness + lives in the scratchpad; the check is: zero off-origin attempts, zero + same-origin failures, zero console errors, schemas actually rendered. +- The **CSP** (`connect-src 'self'`) is the standing enforcement: an external + origin that sneaks back in breaks loudly in prod, on an ordinary day, instead + of silently at the party. + +`script-src`/`style-src` are deliberately permissive for now — Spyglass/deepslate +wasm+eval needs were not characterised under deadline. Tightening them is a +post-party task; loosening `connect-src` is not. + +## Party-day notes + +- HSTS is `max-age=31536000; includeSubDomains` on every vhost, so **there is no + HTTP fallback and no cert-warning click-through**. The cert must be valid. +- LE certs bind **names, not IPs**, so a party DNS override pointing + `generators.${BASE_DOMAIN}` at the LAN IP validates fine — provided the cert + is unexpired and was issued while WAN existed. diff --git a/tooling/sync-generators-data.sh b/tooling/sync-generators-data.sh new file mode 100755 index 0000000..b750c75 --- /dev/null +++ b/tooling/sync-generators-data.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# sync-generators-data.sh — mirror every remote data source the generators app +# reads, so generators.${BASE_DOMAIN} works with ZERO internet at party time. +# +# The generators app (a pinned fork of misode.github.io) is 100% CLIENT-SIDE: +# every data fetch comes from the player's browser, not from this host. So +# "cochi has no WAN" protects nothing on its own — the URLs themselves have to +# point at our own origin. The fork rewrites two constants: +# +# DataFetcher.ts mcmetaUrl = '/mcmeta' +# vanillaMcdocUrl = '/vanilla-mcdoc' +# +# and it builds paths as `${mcmetaUrl}/${ref}-${type}/...`, so the directory +# layout below is not arbitrary — it mirrors upstream's URL shape exactly, +# which is what keeps the fork diff to two lines instead of a rewrite. +# +# Serves at: +# /mcmeta/-{summary,data,atlas,assets}/... per-version snapshots +# /mcmeta/summary/versions/data.min.json dynamic version list +# /vanilla-mcdoc/generated/symbols.json THE schema source +# +# mcmeta publishes per-version snapshots as TAGS, not branches (~4000 of them), +# hence `--branch --depth 1` below. +# +# Needs WAN. Run deliberately, before the party — never at the venue. +# Output dir is gitignored; only this script is tracked. +# +# Cautious by design: halts on any error, verifies each expected file exists +# rather than trusting a clean exit, and refuses to leave a half-synced tree in +# place. Idempotent — re-runs replace each source atomically. +set -euo pipefail + +cd "$(dirname "$0")/.." # repo root + +log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; } + +command -v git >/dev/null || die "git required" + +# The MC version the server runs. mcmeta tags are -; the generators +# UI is locked to the config.json entry {id: '1.21', ref: '1.21.1'}, so this +# MUST stay in step with that ref and with NEOFORGE/VERSION in docker-compose. +MC_REF="${MC_REF:-1.21.1}" + +OUT="${GENERATORS_DATA_DIR:-generators-data}" +MCMETA_REPO="https://github.com/misode/mcmeta.git" +MCDOC_REPO="https://github.com/SpyglassMC/vanilla-mcdoc.git" + +# Each entry: :. +# +# assets-tiny is assets MINUS the ~hundreds of MB of .ogg sound files. We serve +# it AT the `-assets` path because that is what the app requests (lang files, +# textures, models all live there). Consequence: sound PLAYBACK 404s, which is +# why the fork deletes the sounds generator from the UI. If you ever re-add it, +# swap this to the full `-assets` tag and re-measure the disk cost first. +SOURCES=( + "${MC_REF}-summary:${MC_REF}-summary" + "${MC_REF}-data:${MC_REF}-data" + "${MC_REF}-atlas:${MC_REF}-atlas" + "${MC_REF}-assets-tiny:${MC_REF}-assets" +) + +# One file per source that MUST exist afterwards. A clean `git clone` exit does +# not prove the tag held what the app needs — a renamed path upstream would +# sync "successfully" and then die in the browser, offline, at the party. +declare -A SENTINEL=( + ["${MC_REF}-summary"]="registries/data.min.json" + ["${MC_REF}-data"]="data/minecraft/worldgen/biome/plains.json" + ["${MC_REF}-atlas"]="all/atlas.png" + ["${MC_REF}-assets"]="assets/minecraft/lang/en_us.json" +) + +# Shallow-clone one ref into a temp dir, verify its sentinel, then swap it in. +# Never deletes the live copy until the new one has been verified. +sync_ref() { + local repo="$1" ref="$2" dest="$3" sentinel="$4" + local tmp="${dest}.tmp.$$" + + rm -rf "$tmp" + git clone -q --depth 1 --branch "$ref" "$repo" "$tmp" \ + || die "clone failed: $repo @ $ref" + rm -rf "$tmp/.git" + + [ -f "$tmp/$sentinel" ] || { + rm -rf "$tmp" + die "$ref synced but '$sentinel' is missing — upstream layout changed. + Refusing to install a tree the app cannot read. Investigate before retrying." + } + + rm -rf "$dest" + mv "$tmp" "$dest" + printf ' %s -> %s (%s)\n' "$ref" "$dest" "$(du -sh "$dest" | cut -f1)" +} + +log "mirroring generators data for MC ${MC_REF} -> ${OUT}/" +mkdir -p "$OUT/mcmeta" + +for entry in "${SOURCES[@]}"; do + tag="${entry%%:*}" + dir="${entry##*:}" + log "mcmeta tag ${tag}" + sync_ref "$MCMETA_REPO" "$tag" "$OUT/mcmeta/$dir" "${SENTINEL[$dir]}" +done + +# The dynamic version list. NOT a version tag — DataFetcher.ts:205 fetches it +# from the live `summary` BRANCH head, and it runs during Spyglass init. Miss +# this one and the app dead-ends offline while looking perfectly healthy online. +# It is a single file, so fetch it directly rather than cloning the whole +# branch (which is another ~31M of the LATEST version's summary, all unused). +log "mcmeta branch summary (versions/data.min.json only)" +command -v curl >/dev/null || die "curl required" +mkdir -p "$OUT/mcmeta/summary/versions" +curl -fsSL "https://raw.githubusercontent.com/misode/mcmeta/summary/versions/data.min.json" \ + -o "$OUT/mcmeta/summary/versions/data.min.json.tmp" \ + || die "failed to fetch summary/versions/data.min.json" +# Verify it parses as JSON before installing it — a captive-portal HTML error +# page would download with a clean exit code and break Spyglass init offline. +python3 -c "import json,sys; json.load(open(sys.argv[1]))" \ + "$OUT/mcmeta/summary/versions/data.min.json.tmp" \ + || die "summary/versions/data.min.json is not valid JSON — refusing to install it" +mv "$OUT/mcmeta/summary/versions/data.min.json.tmp" \ + "$OUT/mcmeta/summary/versions/data.min.json" +printf ' summary/versions/data.min.json (%s)\n' \ + "$(du -h "$OUT/mcmeta/summary/versions/data.min.json" | cut -f1)" + +# The schema source. Without it there are no schemas, no validation, no +# generator — this is the single most load-bearing file in the mirror. +# +# The app requests /vanilla-mcdoc/generated/symbols.json. In the upstream URL +# that decomposes as // = vanilla-mcdoc / branch `generated` / +# `symbols.json` — `generated` is a BRANCH NAME, not a directory, and +# symbols.json sits at the branch root. So the clone lands in a directory named +# after the branch to reproduce the same URL shape locally. +log "vanilla-mcdoc branch generated" +sync_ref "$MCDOC_REPO" "generated" "$OUT/vanilla-mcdoc/generated" "symbols.json" + +log "done — $(du -sh "$OUT" | cut -f1) in ${OUT}/" +echo " mount it read-only into the generators container (docker-compose.yml)" +echo " verify offline BEFORE the party: block all non-origin requests in" +echo " DevTools and walk the generators you actually intend to use."