Doc images are no longer committed to this repo. They're stored in a shared Cloudflare R2
bucket (websites-images, under the atom-docs/ key prefix so they don't collide with other
properties in the same bucket) and served at their usual /docs/atom/img/... URLs by
worker/index.ts, a small Worker that sits in front of this site's
static assets.
This site is a Next.js static export (output: 'export' in next.config.mjs) deployed
as plain Cloudflare Workers static assets -- there's no Next.js server runtime in production,
so nothing like @cloudflare/next-on-pages or @opennextjs/cloudflare applies, and no
per-request Next.js code path exists to hang an R2 lookup off of. worker/index.ts is a
minimal, hand-written Worker (not part of Next.js) that Cloudflare only invokes as a fallback
when a request doesn't match a static asset (run_worker_first: false, the default -- see
wrangler.jsonc). Doc images aren't part of the static export output, so every request under
/docs/atom/img/... falls through to it automatically; everything else (every actual page,
_next/static, etc.) is served directly from the assets directory without ever touching this
Worker.
Fumadocs' default MDX pipeline (remark-image, useImport: true) turns
 into a static import of the file from public/, which Next bundles
into a content-hashed _next/static/media/<hash>.png URL at build time. That requires the
source file on local disk at build time, and the URL changes every time the image's content
changes -- neither works once the file only lives in R2. source.config.ts disables that
plugin (remarkImageOptions: false), so /img/... paths in MDX stay literal, and
components/doc-image.tsx renders them as a plain, zoomable
<img> (fumadocs-ui's ImageZoom wrapping a plain element, not next/image) -- the src
is still basePath-prefixed manually, same pattern as components/search.tsx, but there's no
width/height requirement and nothing to keep in sync when an image changes.
Authoring is unchanged -- MDX content already referenced doc images by their final
/img/... path from the start (there was never a relative-path convention to preserve here),
so nothing about how you write  needs to change.
-
Create
scripts/.env.publish-imagefrom the template:cp scripts/.env.publish-image.example scripts/.env.publish-image
-
Create a Cloudflare API token: dashboard -> My Profile -> API Tokens -> Create Token -> Custom Token, with both permissions on the same token:
Workers R2 Storage: EditZone -> Cache Purge -> Purge, Zone Resources scoped to theabsmach.euzone
(If you already hold the token used for the main
absmach-websiterepo'spublish-imagescript, it covers the same bucket and zone -- you can reuse it here instead of creating a new one.) -
Paste the token into
CLOUDFLARE_API_TOKENinscripts/.env.publish-image. The zone ID is already filled in (it's not secret, safe to share/commit -- it can't authenticate anything by itself). -
Sanity-check the token before first use:
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"Should return
"status":"active". If it doesn't, the token value itself is wrong (bad copy/paste, expired, revoked) -- fix that before troubleshooting anything else.
scripts/.env.publish-image is gitignored. Never commit it, never paste the token value into
a PR, issue, or chat.
pnpm run publish-image <local-file> <public-path><public-path> must start with img/ and match the path already written (or about to be
written) into the MDX content, e.g.:
pnpm run publish-image ./roles-list.png img/user-guide/roles/roles-list-populated.png
# -> https://www.absmach.eu/docs/atom/img/user-guide/roles/roles-list-populated.png
# -> reference in MDX as: The script does two things, in order:
wrangler r2 object put ... --remote-- uploads to the real bucket.--remoteis required; without it,wranglersilently writes to a local simulated bucket and prints a normal-looking "Upload complete" with no error, and the object is never actually live.- Purges that exact URL from Cloudflare's edge cache (
POST /zones/{id}/purge_cache), so the update is visible within seconds instead of waiting out the cache TTL.
If you re-run the same command for an existing path, it overwrites the object in place and purges again -- that's the intended way to update an image without changing its URL.
pnpm run dev (plain next dev) never shows doc images, published or not -- it's a bare
Next.js dev server with no knowledge of wrangler.jsonc's Worker/routing config, so the
image-proxy Worker never runs and /docs/atom/img/* 404s. This is expected, not a bug; it's
fine for content/layout work where the images themselves don't matter.
To actually see real, published images locally, run the site the same way it's deployed
(pnpm run deploy uses plain wrangler deploy, not wrangler pages deploy -- this repo
is a Workers-with-static-assets project, not a Pages project, so wrangler pages dev is the
wrong command and won't pick up the R2 binding at all):
pnpm run preview # build + wrangler dev in one stepThis runs the real Worker in front of the real static export, and -- because
wrangler.jsonc's R2 binding has "remote": true -- IMAGES_BUCKET connects to the actual
websites-images bucket instead of an empty local simulator, so any image already published
via publish-image.mjs (or the one you're about to publish) renders exactly as it would in
production. Requires being logged in (wrangler whoami; wrangler login if not) with access
to the account that owns websites-images -- no token file needed for this, remote bindings
piggyback on your own Wrangler OAuth session, separate from publish-image.mjs's
maintainer-only CLOUDFLARE_API_TOKEN.
Needs wrangler >= 4.120.0 -- earlier versions have a bug where a remote R2 binding throws
SyntaxError: Unexpected end of JSON input instead of actually proxying to R2.
public/img/ is gitignored. For a next dev preview with working images, drop the file
there locally under the same path used in MDX (e.g. public/img/user-guide/roles/foo.png for
/img/user-guide/roles/foo.png) -- Next's dev server serves public/ under the site's
basePath automatically, so it resolves at the exact same URL production does. It just won't be
committed, and pnpm run build's nest-static-export.mjs step strips public/img from the
deployed output either way, so a leftover local copy can never accidentally ship instead of
the R2-backed version.
To test the actual production path -- Worker + static assets + R2 binding together, the way Cloudflare will actually serve it -- see "Previewing images locally" above.
The 88 images removed from public/img/ have already been uploaded to the real R2 bucket and
spot-checked byte-for-byte against the originals. Nothing further to do here unless an image
needs updating -- use publish-image for that, same as any other image.
This repo is public. The risk isn't the script being visible -- it's inert without a
credential. The risk is credential distribution: whoever holds CLOUDFLARE_API_TOKEN can
write to the shared bucket. So nobody, internal or external, gets a personal R2 token. Only a
maintainer, holding this one scoped token, runs publish-image.
Practical flow for a PR that adds a doc image: the contributor attaches the image to the PR
description or a comment the normal GitHub way. A maintainer reviewing the PR runs
pnpm run publish-image locally before merging, then approves.
-
Local file not found: ---- you ranpnpm run publish-image -- <file> <dest>. pnpm forwards a leading--to the script literally instead of stripping it like npm does. The script strips it defensively now, but plainpnpm run publish-image <file> <dest>(no--) is the form to use. -
Destination must start with "img/"-- the second argument is the path as it appears after/docs/atom/in the final URL (and after the leading/in MDXsrc), e.g.img/user-guide/roles/foo.png, notuser-guide/roles/foo.pngor a full URL. -
Resource location: localin the upload output -- means--remotedidn't get applied for some reason (e.g. running the underlyingwranglercommand by hand without copying the full flag list from the script). The object was never written to the real bucket even though the CLI reports success. Always usepnpm run publish-image, or add--remoteyourself if invoking wrangler directly. -
Cache purge failed/Authentication error(code 10000) -- Cloudflare reuses this code for both "bad token" and "token valid but missing this permission." Run the token verify curl command above first to rule out a bad token. If that succeeds, the token is missingZone -> Cache Purge -> Purgefor theabsmach.euzone, or that permission's Zone Resources selector doesn't include it -- edit the token in the dashboard and add it. -
To confirm an object actually made it into the bucket after a
--remoteupload:wrangler r2 object get websites-images/atom-docs/<path-after-img/> --remote --file=/tmp/check