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.
| Command | Direction | What it does |
|---|---|---|
assets build | source JPEGs → web/public/img/ | Re-encode each manifest photo to AVIF/WebP/JPEG at every width. |
assets upload | web/public/img/ → bucket | Push recognized image files to gs://<project>-assets/img/<slug>/…. |
assets pull | bucket → web/public/img/ | Download the published image files back for local development. |
assets fetch-referenced | origin → web/public/ | Hydrate content img/… refs over public HTTPS (no ADC). |
assets stub-referenced | refs → output root | Write tiny placeholders for content img/… paths. |
assets verify | web/content refs → chosen origin | Fetch 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.