Public assets

Neon Law Navigator's marketing, workshop, and blog pages render public images through the shared asset lane. Responsive photos use views::assets::responsive_picture; hand-authored blog or illustration heroes can be dropped directly under web/public/img/<slug>/ as PNGs. The bytes are never stored in git. Production serves them from the public Google Cloud Storage origin, while local development and the ephemeral KIND integration image hydrate web/public/img/ from that same public origin. That keeps the repository small (a clone is code, not megabytes of binaries) without making the local test harness depend on a runtime GCP mount.

The four commands

The navigator assets subcommands form a build → publish → restore → verify loop. For responsive photos, the views::assets::GALLERY manifest and the width set (WIDTHS = [400, 800, 1200]) are the single source of truth shared with the view layer, so adding a photo is a manifest edit plus a JPEG — never a code change. Standalone blog or illustration assets do not go through assets build; put the finished PNG at its final web/public/img/<slug>/<name>.png path, then use assets upload to publish it.

CommandDirectionWhat it does
assets buildsource JPEGs → web/public/img/Re-encode each manifest photo to AVIF/WebP/JPEG at every width.
assets uploadweb/public/img/ → bucketPush recognized image files to gs://<project>-assets/img/<slug>/….
assets pullbucket → web/public/img/Download the published image files back for local development.
assets fetch-referencedorigin → web/public/Hydrate content img/… refs over public HTTPS (no ADC).
assets stub-referencedrefs → output rootWrite tiny placeholders for content img/… paths.
assets verifyweb/content refs → chosen originFetch every content img/… reference; fail if any 404s.

build and upload are the publish path for responsive photos, run by whoever curates the gallery. For a finished PNG hero, only upload is needed. pull is the restore path every developer runs. verify is the pre-ship guardrail.

Licensed webfonts

The public asset origin also serves GORP Serif. The WOFF2 bytes are never committed or baked into an image: each operator supplies the fonts from its own TrashType delivery and uploads the initial Regular and Bold faces under fonts/gorp-serif/. PageLayout resolves those URLs through NAVIGATOR_ASSET_BASE_URL; without that setting, local development falls back to /public/fonts/gorp-serif/ for a licensed operator's ignored local copies.

cargo run -p cli -- assets fonts upload \
  --dir '/path/to/GORP Serif/WOFF'

The command targets NAVIGATOR_ASSETS_BUCKET (or --bucket) and refuses a partial delivery. Navigator code is Apache-2.0/MIT; GORP Serif is proprietary font software licensed separately from TrashType. Follow the TrashType terms and keep the local notice in web/public/fonts/gorp-serif/LICENSE.txt with the source files. Before the first upload, rerun navigator ops gcp setup so the public assets bucket receives the font-fetch CORS policy.

Verify before shipping

Because the bytes live only in the bucket, a post can merge and reach a release with a hero that 404s in production — the rendered-HTML test only checks the src string, not that the object exists. assets verify closes that gap: it walks every img/… image reference under web/content, fetches each one from the public origin (auth-free HEAD against NAVIGATOR_ASSET_BASE_URL, exactly as a browser would), and exits non-zero listing any that are missing.

NAVIGATOR_ASSET_BASE_URL=https://storage.googleapis.com/<project>-assets cargo run -p cli -- assets verify

Run it after assets upload, before you ship. The deploy workflow's build job verifies the public origin with assets verify --base-url "$PUBLIC_ASSET_ORIGIN", then runs assets stub-referenced --out web/public before baking the ephemeral navigator-web image used by KIND. Those placeholder files carry the same paths as the verified GCS objects, but not the real photo bytes. After dev e2e, the KIND integration job runs assets verify twice: first against the public origin (the production publication gate), then against http://localhost:8080/public (the local serve gate for the stubbed KIND image). A release blocks until both pass. Locally, use --base-url http://localhost:<web-port>/public.

The production origin

In Doppler prd (project navigator, config prd), set NAVIGATOR_ASSET_BASE_URL to the public origin of the assets bucket — https://storage.googleapis.com/<project>-assets, the same bucket as NAVIGATOR_ASSETS_BUCKET, with no gs:// prefix and no trailing slash. The img/ prefix comes from the reference path, so the browser fetches …/<project>-assets/img/<slug>/<file>. navigator ops ship enforces this: it aborts before any rollout if the value is unset, so a prod deploy can never roll a web that resolves images against an empty same-origin /public.

Why the images aren't in git

web/public/img/ is ignored by .gitignore. A fresh clone has empty image slots — the rest of web/public (Bootstrap, brand SVGs, vendored JS/CSS) stays tracked and still ships in the image, but page images do not. The production app resolves image URLs through views::assets::asset_url, which prefixes NAVIGATOR_ASSET_BASE_URL (the bucket's public origin), so browsers fetch images straight from the bucket. The single cross-origin allowance in the Content-Security-Policy (img-src) is exactly this. The CI KIND image is the exception: it bakes temporary placeholders under /public so browser tests exercise the local static-file path without requiring GCP credentials or real photo bytes.

CI placeholders and local development

assets stub-referenced is for CI image packaging checks. It scans the same web/content Markdown references as verify, creates each parent directory under the chosen output root, and writes a minimal valid image file matching the referenced extension. Run public-origin verify first; the stub command does not contact GCS and does not prove publication. Its job is only to let the local /public/img/... route serve something at the exact keys already proven live in the public bucket.

Because the slots are empty on a fresh clone, the dev /public mount 404s every page image until you populate web/public/img/. The fast path is to pull the already-published files from the bucket — no source JPEGs, no re-encode, and no generated-image source needed:

export NAVIGATOR_ASSETS_BUCKET="$(gcloud config get-value project)-assets"   # or set it in .env
cargo run -p cli -- assets pull

This downloads every supported image file (.avif, .webp, .jpg, .jpeg, .png) under the bucket's img/ prefix into web/public/img/<slug>/…, byte-identical to what was uploaded. Run it once after cloning, and again whenever public page images change; the KIND dev loop then serves the images from /public with no further setup. Auth is ADC (gcloud auth application-default login); this operator command targets real GCS.

If you are curating the gallery (adding or replacing a responsive photo), use build from the source JPEGs and then upload instead — see the public-image-assets section of cli/README.md. If you are adding a blog hero PNG, put it under web/public/img/<slug>/, verify it locally, then run assets upload.