diff --git a/.gitignore b/.gitignore
index 77fddec..e0f854a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,6 +69,7 @@ web_modules/
.env
.env.*
!.env.example
+!scripts/.env.publish-image.example
# parcel-bundler cache (https://parceljs.org/)
.cache
diff --git a/README.md b/README.md
index f0d0ee2..2e4daf9 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,14 @@ This site uses:
- **Next.js static export** — `next build` outputs static files to `out/`
- **Next.js `basePath`** — generates links and assets under `/docs/hardware`
-- **Post-build nesting** — `scripts/nest-static-export.mjs` moves the export under `out/docs/hardware/` so Cloudflare static assets can serve it from the route prefix without custom Worker code
+- **Post-build nesting** — `scripts/nest-static-export.mjs` moves the export under `out/docs/hardware/` so Cloudflare static assets can serve it from the route prefix
+- **Worker script (`worker/index.ts`)** — thin `main` script in front of the static
+ assets. Almost every request is served by the `ASSETS` binding untouched
+ (`run_worker_first` defaults to `false`, so Cloudflare only invokes the Worker when no
+ static file matches). The one exception is `/docs/hardware/img/*`, which the Worker
+ answers directly from the shared R2 bucket (`IMAGES_BUCKET`) — see
+ [`scripts/README.md`](./scripts/README.md) for why docs content images are no longer
+ committed to this repo.
### Cloudflare build settings (Dashboard)
@@ -46,8 +53,11 @@ flowchart LR
subgraph Runtime_Request_Flow
U[Browser request] --> H[Cloudflare static asset route]
- H --> J[Static asset lookup]
+ H -->|asset found| J[Static asset lookup]
+ H -->|no asset matches| K[worker/index.ts]
+ K -->|"/docs/hardware/img/*"| R[(R2: websites-images)]
J --> U
+ R --> U
end
```
@@ -73,6 +83,8 @@ Set this as a Cloudflare build variable so it is embedded into the static output
| `src/lib/source.ts` | Fumadocs source adapter |
| `src/lib/layout.shared.tsx` | Shared layout options (nav, logo) |
| `scripts/nest-static-export.mjs` | Moves static export under `/docs/hardware` |
+| `worker/index.ts` | Worker script: falls back to `ASSETS`, proxies `/docs/hardware/img/*` from R2 |
+| `scripts/publish-image.mjs` | Maintainer-only: uploads a docs image to R2 and purges its cache (see `scripts/README.md`) |
## Learn More
diff --git a/content/docs/base-board/index.mdx b/content/docs/base-board/index.mdx
index 3fea324..7c95287 100644
--- a/content/docs/base-board/index.mdx
+++ b/content/docs/base-board/index.mdx
@@ -5,7 +5,7 @@ description: Modular expansion board for S0 IoT Gateway
# S0 Base Board
-
+
The **S0 Base Board** is a modular expansion board designed to power and extend the functionality of the S0 compute module. It integrates critical components for industrial IoT and smart metering applications, making it ideal for professional deployments.
@@ -39,7 +39,7 @@ The Base Board transforms the S0 module into a complete gateway solution. The S0
## Getting Started
-
+
### Powering the Board
diff --git a/content/docs/base-board/pinouts.mdx b/content/docs/base-board/pinouts.mdx
index d47e682..33572be 100644
--- a/content/docs/base-board/pinouts.mdx
+++ b/content/docs/base-board/pinouts.mdx
@@ -5,7 +5,7 @@ description: S0 Base Board pin assignments and header descriptions
# Base Board Pinouts
-
+
## DC Header Pinout (`J6`)
diff --git a/content/docs/base-board/schematics.mdx b/content/docs/base-board/schematics.mdx
index 5344066..7aec115 100644
--- a/content/docs/base-board/schematics.mdx
+++ b/content/docs/base-board/schematics.mdx
@@ -7,30 +7,30 @@ description: S0 Base Board circuit schematics and diagrams
## Ethernet Circuit
-
+
-
+
-
+
## M-Bus Circuit
-
+
## SD Card Circuit
-
+
## Board Frame and Connectors
-
+
-
+
## Power Circuitry
-
+
-
+
-
+
diff --git a/content/docs/images/arch.png b/content/docs/images/arch.png
deleted file mode 100644
index 963edea..0000000
Binary files a/content/docs/images/arch.png and /dev/null differ
diff --git a/content/docs/images/architecture.png b/content/docs/images/architecture.png
deleted file mode 100644
index 35b9474..0000000
Binary files a/content/docs/images/architecture.png and /dev/null differ
diff --git a/content/docs/images/baseboard-front.png b/content/docs/images/baseboard-front.png
deleted file mode 100644
index c5fec7f..0000000
Binary files a/content/docs/images/baseboard-front.png and /dev/null differ
diff --git a/content/docs/images/baseboard-labeled.png b/content/docs/images/baseboard-labeled.png
deleted file mode 100644
index 4f8a814..0000000
Binary files a/content/docs/images/baseboard-labeled.png and /dev/null differ
diff --git a/content/docs/images/baseboard-pinout.svg b/content/docs/images/baseboard-pinout.svg
deleted file mode 100644
index 442eb06..0000000
--- a/content/docs/images/baseboard-pinout.svg
+++ /dev/null
@@ -1,2069 +0,0 @@
-
-
diff --git a/content/docs/images/baseconnectors.png b/content/docs/images/baseconnectors.png
deleted file mode 100644
index d73893e..0000000
Binary files a/content/docs/images/baseconnectors.png and /dev/null differ
diff --git a/content/docs/images/baseframe.png b/content/docs/images/baseframe.png
deleted file mode 100644
index 8b38694..0000000
Binary files a/content/docs/images/baseframe.png and /dev/null differ
diff --git a/content/docs/images/battcharger.png b/content/docs/images/battcharger.png
deleted file mode 100644
index a310515..0000000
Binary files a/content/docs/images/battcharger.png and /dev/null differ
diff --git a/content/docs/images/bb-label.png b/content/docs/images/bb-label.png
deleted file mode 100644
index 795187e..0000000
Binary files a/content/docs/images/bb-label.png and /dev/null differ
diff --git a/content/docs/images/beagleframe.png b/content/docs/images/beagleframe.png
deleted file mode 100644
index 180016b..0000000
Binary files a/content/docs/images/beagleframe.png and /dev/null differ
diff --git a/content/docs/images/boardheaders.png b/content/docs/images/boardheaders.png
deleted file mode 100644
index 8cb96f2..0000000
Binary files a/content/docs/images/boardheaders.png and /dev/null differ
diff --git a/content/docs/images/buckconverter.png b/content/docs/images/buckconverter.png
deleted file mode 100644
index bf5f71f..0000000
Binary files a/content/docs/images/buckconverter.png and /dev/null differ
diff --git a/content/docs/images/decaps-sim.png b/content/docs/images/decaps-sim.png
deleted file mode 100644
index 1100eda..0000000
Binary files a/content/docs/images/decaps-sim.png and /dev/null differ
diff --git a/content/docs/images/decapseth.png b/content/docs/images/decapseth.png
deleted file mode 100644
index 86d51f1..0000000
Binary files a/content/docs/images/decapseth.png and /dev/null differ
diff --git a/content/docs/images/decoups-sim.png b/content/docs/images/decoups-sim.png
deleted file mode 100644
index 69ca5c9..0000000
Binary files a/content/docs/images/decoups-sim.png and /dev/null differ
diff --git a/content/docs/images/esp-reset.png b/content/docs/images/esp-reset.png
deleted file mode 100644
index 2959566..0000000
Binary files a/content/docs/images/esp-reset.png and /dev/null differ
diff --git a/content/docs/images/esp32.png b/content/docs/images/esp32.png
deleted file mode 100644
index ca2e5d0..0000000
Binary files a/content/docs/images/esp32.png and /dev/null differ
diff --git a/content/docs/images/ethernet.png b/content/docs/images/ethernet.png
deleted file mode 100644
index 506fb4a..0000000
Binary files a/content/docs/images/ethernet.png and /dev/null differ
diff --git a/content/docs/images/headers.png b/content/docs/images/headers.png
deleted file mode 100644
index 12ee807..0000000
Binary files a/content/docs/images/headers.png and /dev/null differ
diff --git a/content/docs/images/image-1.png b/content/docs/images/image-1.png
deleted file mode 100644
index 70e94ed..0000000
Binary files a/content/docs/images/image-1.png and /dev/null differ
diff --git a/content/docs/images/image-10.png b/content/docs/images/image-10.png
deleted file mode 100644
index 1f4a184..0000000
Binary files a/content/docs/images/image-10.png and /dev/null differ
diff --git a/content/docs/images/image-3.png b/content/docs/images/image-3.png
deleted file mode 100644
index e8a78d8..0000000
Binary files a/content/docs/images/image-3.png and /dev/null differ
diff --git a/content/docs/images/image-5.png b/content/docs/images/image-5.png
deleted file mode 100644
index 72d36cf..0000000
Binary files a/content/docs/images/image-5.png and /dev/null differ
diff --git a/content/docs/images/image-6.png b/content/docs/images/image-6.png
deleted file mode 100644
index 1663956..0000000
Binary files a/content/docs/images/image-6.png and /dev/null differ
diff --git a/content/docs/images/image-7.png b/content/docs/images/image-7.png
deleted file mode 100644
index 6c478df..0000000
Binary files a/content/docs/images/image-7.png and /dev/null differ
diff --git a/content/docs/images/image.png b/content/docs/images/image.png
deleted file mode 100644
index eff30d6..0000000
Binary files a/content/docs/images/image.png and /dev/null differ
diff --git a/content/docs/images/jackmagnetics.png b/content/docs/images/jackmagnetics.png
deleted file mode 100644
index 875131c..0000000
Binary files a/content/docs/images/jackmagnetics.png and /dev/null differ
diff --git a/content/docs/images/magneticsandjack.png b/content/docs/images/magneticsandjack.png
deleted file mode 100644
index 8ca6f10..0000000
Binary files a/content/docs/images/magneticsandjack.png and /dev/null differ
diff --git a/content/docs/images/mbus.png b/content/docs/images/mbus.png
deleted file mode 100644
index d13ce3b..0000000
Binary files a/content/docs/images/mbus.png and /dev/null differ
diff --git a/content/docs/images/monitoring.png b/content/docs/images/monitoring.png
deleted file mode 100644
index 5a2cd4c..0000000
Binary files a/content/docs/images/monitoring.png and /dev/null differ
diff --git a/content/docs/images/negotiatemodes.png b/content/docs/images/negotiatemodes.png
deleted file mode 100644
index e0a7db3..0000000
Binary files a/content/docs/images/negotiatemodes.png and /dev/null differ
diff --git a/content/docs/images/powerbase.png b/content/docs/images/powerbase.png
deleted file mode 100644
index e159a17..0000000
Binary files a/content/docs/images/powerbase.png and /dev/null differ
diff --git a/content/docs/images/proxy.png b/content/docs/images/proxy.png
deleted file mode 100644
index 360a52b..0000000
Binary files a/content/docs/images/proxy.png and /dev/null differ
diff --git a/content/docs/images/rcs2lp.png b/content/docs/images/rcs2lp.png
deleted file mode 100644
index a1d5303..0000000
Binary files a/content/docs/images/rcs2lp.png and /dev/null differ
diff --git a/content/docs/images/s0-angled.jpg b/content/docs/images/s0-angled.jpg
deleted file mode 100644
index 70306ff..0000000
Binary files a/content/docs/images/s0-angled.jpg and /dev/null differ
diff --git a/content/docs/images/s0-angled.png b/content/docs/images/s0-angled.png
deleted file mode 100644
index 70306ff..0000000
Binary files a/content/docs/images/s0-angled.png and /dev/null differ
diff --git a/content/docs/images/s0-back.jpg b/content/docs/images/s0-back.jpg
deleted file mode 100644
index 5b60df8..0000000
Binary files a/content/docs/images/s0-back.jpg and /dev/null differ
diff --git a/content/docs/images/s0-back.png b/content/docs/images/s0-back.png
deleted file mode 100644
index 5b60df8..0000000
Binary files a/content/docs/images/s0-back.png and /dev/null differ
diff --git a/content/docs/images/s0-base-board-angled.jpg b/content/docs/images/s0-base-board-angled.jpg
deleted file mode 100644
index 842404f..0000000
Binary files a/content/docs/images/s0-base-board-angled.jpg and /dev/null differ
diff --git a/content/docs/images/s0-base-board-angled.png b/content/docs/images/s0-base-board-angled.png
deleted file mode 100644
index 842404f..0000000
Binary files a/content/docs/images/s0-base-board-angled.png and /dev/null differ
diff --git a/content/docs/images/s0-base-board-back.jpg b/content/docs/images/s0-base-board-back.jpg
deleted file mode 100644
index 84d2bb1..0000000
Binary files a/content/docs/images/s0-base-board-back.jpg and /dev/null differ
diff --git a/content/docs/images/s0-base-board-back.png b/content/docs/images/s0-base-board-back.png
deleted file mode 100644
index 84d2bb1..0000000
Binary files a/content/docs/images/s0-base-board-back.png and /dev/null differ
diff --git a/content/docs/images/s0-edited.png b/content/docs/images/s0-edited.png
deleted file mode 100644
index e27ccca..0000000
Binary files a/content/docs/images/s0-edited.png and /dev/null differ
diff --git a/content/docs/images/s0-front.png b/content/docs/images/s0-front.png
deleted file mode 100644
index de5eee3..0000000
Binary files a/content/docs/images/s0-front.png and /dev/null differ
diff --git a/content/docs/images/s0-labeled.png b/content/docs/images/s0-labeled.png
deleted file mode 100644
index 92ab685..0000000
Binary files a/content/docs/images/s0-labeled.png and /dev/null differ
diff --git a/content/docs/images/s0.png b/content/docs/images/s0.png
deleted file mode 100644
index 1bbfd02..0000000
Binary files a/content/docs/images/s0.png and /dev/null differ
diff --git a/content/docs/images/s0_pinout.svg b/content/docs/images/s0_pinout.svg
deleted file mode 100644
index daa1c12..0000000
--- a/content/docs/images/s0_pinout.svg
+++ /dev/null
@@ -1,2128 +0,0 @@
-
-
diff --git a/content/docs/images/sdcard.png b/content/docs/images/sdcard.png
deleted file mode 100644
index 2e807fd..0000000
Binary files a/content/docs/images/sdcard.png and /dev/null differ
diff --git a/content/docs/images/sim7080g.png b/content/docs/images/sim7080g.png
deleted file mode 100644
index 61c0241..0000000
Binary files a/content/docs/images/sim7080g.png and /dev/null differ
diff --git a/content/docs/images/sim7080gstatus.png b/content/docs/images/sim7080gstatus.png
deleted file mode 100644
index 62339c2..0000000
Binary files a/content/docs/images/sim7080gstatus.png and /dev/null differ
diff --git a/content/docs/images/tblock.png b/content/docs/images/tblock.png
deleted file mode 100644
index ff3ea1f..0000000
Binary files a/content/docs/images/tblock.png and /dev/null differ
diff --git a/content/docs/images/voltconvert.png b/content/docs/images/voltconvert.png
deleted file mode 100644
index e2a4c9a..0000000
Binary files a/content/docs/images/voltconvert.png and /dev/null differ
diff --git a/content/docs/images/wiredmbus.png b/content/docs/images/wiredmbus.png
deleted file mode 100644
index 48191f4..0000000
Binary files a/content/docs/images/wiredmbus.png and /dev/null differ
diff --git a/package.json b/package.json
index 4995c06..3a14cbe 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"lint": "biome check .",
"lint:fix": "biome check src --write",
"check:assets": "bash scripts/check-assets.sh",
+ "publish-image": "node scripts/publish-image.mjs",
"optimize:images": "f() { webp=\"${1%.*}.webp\"; magick \"$1\" -resize 1200x1200\\> -quality 82 \"$webp\" && rm \"$1\" && echo \"Converted $1 → $webp (original removed)\"; }; f",
"optimize:videos": "f() { ffmpeg -y -i \"$1\" -vcodec libx264 -crf 30 -preset fast -an -movflags +faststart \"${1%.mp4}-opt.mp4\" && mv \"${1%.mp4}-opt.mp4\" \"$1\" && echo \"Re-encoded $1\"; }; f",
"generate:poster": "f() { ffmpeg -y -i \"$1\" -ss 0 -vframes 1 -update 1 -q:v 3 \"${1%.mp4}-poster.jpg\" && echo \"Poster → ${1%.mp4}-poster.jpg\"; }; f"
diff --git a/scripts/.env.publish-image.example b/scripts/.env.publish-image.example
new file mode 100644
index 0000000..129f5c3
--- /dev/null
+++ b/scripts/.env.publish-image.example
@@ -0,0 +1,14 @@
+# Copy this file to scripts/.env.publish-image and fill in the values below.
+# The copy is gitignored (.env.* pattern in .gitignore) — never commit it or
+# paste the token value into a PR, issue, or chat.
+
+# Cloudflare API token with both permissions on the same token:
+# - Workers R2 Storage: Edit (scoped to the "websites-images" bucket)
+# - Zone -> Cache Purge -> Purge (scoped to the zone fronting this site)
+CLOUDFLARE_API_TOKEN=
+
+# Not secret - the www.absmach.eu zone ID. This site (docs/hardware) is
+# served under that domain (confirmed via NEXT_PUBLIC_BASE_URL and
+# public/_redirects); reused from the same zone already confirmed for the
+# absmach-website reference repo and the other absmach.eu-domain docs sites.
+CLOUDFLARE_ZONE_ID=9cb2232dc0e21fbfabf9ce52b1834f56
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..88fb927
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,159 @@
+# Publishing docs images (maintainers only)
+
+Content images are no longer committed to this repo. They're stored in a shared
+Cloudflare R2 bucket (`websites-images`, under the `hardware-docs` key prefix so they
+don't collide with other properties in the same bucket) and served at
+`/docs/hardware/img/` by a small Cloudflare Worker script,
+[`worker/index.ts`](../worker/index.ts), that reads the object from R2 and streams it
+back. MDX content references images by that path, e.g.:
+
+```md
+
+```
+
+(`/img/...` becomes `/docs/hardware/img/...` automatically — see `assetPath()` in
+[`src/lib/base-path.ts`](../src/lib/base-path.ts) and its use in
+[`src/mdx-components.tsx`](../src/mdx-components.tsx).)
+
+## Why a Worker script, not a Next.js route
+
+This site is a fully static Next.js export (`output: "export"` in `next.config.mjs`),
+deployed to Cloudflare as static assets with no Next.js server at all — see
+`README.md`'s "Post-build nesting" section. That's also why neither
+`@cloudflare/next-on-pages` nor `@opennextjs/cloudflare` apply here: there's no running
+Next.js request handler on Cloudflare to reach an R2 binding from.
+
+Before this change, images lived under `content/docs/images/`, co-located with their MDX
+files, and were referenced with **relative** markdown paths (e.g. `../images/s0.png`).
+Next's static-export bundler resolved those at build time into content-hashed files
+under `_next/static/media/`, which meant the image bytes had to be physically present in
+the repo just to run `next build` — incompatible with getting them out of git. Content
+images are now referenced by the stable absolute path above instead, so the build has no
+dependency on the files being present at all, and `wrangler.jsonc` gained a `main` Worker
+script (`worker/index.ts`) purely to answer that one route: it falls back from the
+`ASSETS` binding (Cloudflare serves any matching static file directly and only invokes
+this Worker when nothing matches, since `run_worker_first` defaults to `false`) to
+reading `/docs/hardware/img/*` requests straight out of `IMAGES_BUCKET`.
+
+Only maintainers publish images, using [`publish-image.mjs`](./publish-image.mjs). The
+script is safe to have in a public repo because it's inert without a token — nobody can
+upload to the bucket just by reading this file. See "Why maintainer-only" below.
+
+## One-time setup
+
+1. Create `scripts/.env.publish-image` from the template:
+
+ ```bash
+ cp scripts/.env.publish-image.example scripts/.env.publish-image
+ ```
+
+2. Create a Cloudflare API token: dashboard -> **My Profile -> API Tokens -> Create Token
+ -> Custom Token**, with both permissions on the same token:
+ - `Workers R2 Storage: Edit`
+ - `Zone -> Cache Purge -> Purge`, **Zone Resources** scoped to the zone fronting this
+ site (see the TODO in `scripts/.env.publish-image.example` — the zone ID isn't
+ documented anywhere in this repo; confirm it in the dashboard)
+
+3. Paste the token into `CLOUDFLARE_API_TOKEN` and the zone ID into `CLOUDFLARE_ZONE_ID`
+ in `scripts/.env.publish-image`.
+
+4. Sanity-check the token before first use:
+
+ ```bash
+ 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.
+
+## Publishing an image
+
+```bash
+pnpm run publish-image [dest-filename]
+```
+
+`[dest-filename]` defaults to the local file's own name and becomes both the R2 object's
+key and the filename readers request. Example:
+
+```bash
+pnpm run publish-image ./s0-front.png
+# -> r2://websites-images/hardware-docs/s0-front.png
+# -> https://www.absmach.eu/docs/hardware/img/s0-front.png
+# -> reference from MDX as: 
+```
+
+The script does two things, in order:
+
+1. `wrangler r2 object put ... --remote` — uploads to the **real** bucket. `--remote` is
+ required; without it, `wrangler` silently writes to a local simulated bucket and
+ prints a normal-looking "Upload complete" with no error, and the object is never
+ actually live.
+2. 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 filename, it overwrites the object in
+place and purges again — that's the intended way to update an image without changing its
+URL or the MDX that references it.
+
+## Migrating the existing images (one-time)
+
+This branch removes `content/docs/images/` from git. Before merging (or right after, but
+before anyone needs the docs pages that reference them to render images), a maintainer
+needs to publish the images that are actually referenced from MDX. The original files are
+still present in git history (and in any pre-migration checkout) if you need to recover
+them — check out the commit before this change, or `git show :content/docs/images/`.
+
+Only these were actually referenced from MDX at migration time (the repo had accumulated
+a number of unused images under `content/docs/images/` — e.g. `image.png`,
+`image-3.png`, `s0-angled.png`, `mbus.png` — that were dropped rather than migrated,
+since nothing linked to them):
+
+```
+arch.png, baseboard-front.png, baseboard-pinout.svg, baseconnectors.png, baseframe.png,
+battcharger.png, bb-label.png, beagleframe.png, buckconverter.png, decapseth.png,
+decaps-sim.png, esp32.png, esp-reset.png, ethernet.png, headers.png, image-1.png,
+image-10.png, magneticsandjack.png, powerbase.png, rcs2lp.png, s0.png, s0_pinout.svg,
+sdcard.png, sim7080g.png, sim7080gstatus.png, voltconvert.png, wiredmbus.png
+```
+
+For each one, run `pnpm run publish-image ` with the exact
+filename above as `dest-filename` (or as the local file's own name, if it already
+matches) so the R2 key matches what `worker/index.ts` and the MDX references expect.
+
+If you're adding a brand-new image going forward, just run `publish-image` and reference
+`/img/` from the MDX — no separate registration step needed.
+
+## Why maintainer-only
+
+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 an image: the contributor attaches the image to the PR
+the normal GitHub way (drag-and-drop into the description or a comment) and references
+`/img/` from their MDX changes. A maintainer reviewing the PR runs
+`pnpm run publish-image` locally before merging, then approves.
+
+## Troubleshooting
+
+- **`Local file not found: --`** — you ran `pnpm run publish-image -- `. pnpm
+ forwards a leading `--` to the script literally instead of stripping it like npm does.
+ The script strips it defensively, but plain `pnpm run publish-image ` (no `--`)
+ is the form to use.
+- **`Resource location: local` in the upload output** — means `--remote` didn't get
+ applied for some reason (e.g. running the underlying `wrangler` command 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 use `pnpm run publish-image`, or add
+ `--remote` yourself 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
+ missing `Zone -> Cache Purge -> Purge` for the correct zone, 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 `--remote` upload:
+
+ ```bash
+ wrangler r2 object get websites-images/hardware-docs/ --remote --file=/tmp/check
+ ```
diff --git a/scripts/publish-image.mjs b/scripts/publish-image.mjs
new file mode 100644
index 0000000..d7d6a39
--- /dev/null
+++ b/scripts/publish-image.mjs
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+// Maintainer-only. Uploads a docs content image to the shared R2 bucket and
+// purges it from Cloudflare's edge cache, so it's live right after this
+// finishes. Requires CLOUDFLARE_API_TOKEN (scoped: R2 Edit on
+// websites-images + Zone Cache Purge on the zone fronting this site) and
+// CLOUDFLARE_ZONE_ID.
+//
+// Usage:
+// pnpm run publish-image [dest-filename]
+//
+// [dest-filename] defaults to the local file's own name and becomes both
+// the R2 object's key and the filename readers request:
+//
+// pnpm run publish-image ./s0-front.png
+// -> uploaded to r2://websites-images/hardware-docs/s0-front.png
+// -> live at https://www.absmach.eu/docs/hardware/img/s0-front.png
+// -> reference it from MDX as: 
+
+import { execFileSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { basename, extname } from "node:path";
+import process from "node:process";
+
+const BUCKET_NAME = "websites-images";
+// Shared bucket holds assets for multiple properties; this prefix keeps
+// this site's objects from colliding with theirs. Keep in sync with
+// R2_KEY_PREFIX in worker/index.ts.
+const KEY_PREFIX = "hardware-docs";
+
+// Confirmed from README.md's documented Cloudflare build variable
+// (NEXT_PUBLIC_BASE_URL=https://www.absmach.eu/docs/hardware) and
+// public/_redirects, which both only make sense if this Worker is routed
+// from the www.absmach.eu zone at the /docs/hardware path. The docs/CNAME
+// file pointing at hardware.absmach.eu appears to be a leftover from this
+// repo's prior GitHub Pages deployment (see the untouched gh-pages branch
+// and public/.nojekyll) and not where the site is actually served today.
+const SITE_ORIGIN = "https://www.absmach.eu";
+const BASE_PATH = "/docs/hardware";
+
+const MIME_TYPES = {
+ ".webp": "image/webp",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".gif": "image/gif",
+ ".avif": "image/avif",
+ // No video exists in this repo today. If that changes, add the relevant
+ // video MIME types here — the upload/purge logic below is format-agnostic.
+};
+
+try {
+ process.loadEnvFile(new URL("./.env.publish-image", import.meta.url));
+} catch {
+ // No local env file — assume CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID
+ // are already exported (e.g. in CI).
+}
+
+// pnpm forwards a leading "--" to the underlying command instead of
+// stripping it (unlike npm), so tolerate it either way.
+const cliArgs = process.argv.slice(2).filter((arg) => arg !== "--");
+const [localFile, destFilenameArg] = cliArgs;
+
+if (!localFile) {
+ console.error(
+ "Usage: pnpm run publish-image [dest-filename]\n" +
+ "Example: pnpm run publish-image ./s0-front.png",
+ );
+ process.exit(1);
+}
+
+if (!existsSync(localFile)) {
+ console.error(`Local file not found: ${localFile}`);
+ process.exit(1);
+}
+
+const destFilename = destFilenameArg || basename(localFile);
+
+const contentType = MIME_TYPES[extname(destFilename).toLowerCase()];
+if (!contentType) {
+ console.error(`Unrecognized file extension for: ${destFilename}`);
+ process.exit(1);
+}
+
+const { CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID } = process.env;
+if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ZONE_ID) {
+ console.error(
+ "Missing CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ZONE_ID.\n" +
+ "Copy scripts/.env.publish-image.example to scripts/.env.publish-image and fill in both.",
+ );
+ process.exit(1);
+}
+
+const objectPath = `${BUCKET_NAME}/${KEY_PREFIX}/${destFilename}`;
+
+console.log(`Uploading ${localFile} -> r2://${objectPath}`);
+execFileSync(
+ "wrangler",
+ [
+ "r2",
+ "object",
+ "put",
+ objectPath,
+ `--file=${localFile}`,
+ `--content-type=${contentType}`,
+ "--remote",
+ ],
+ { stdio: "inherit", env: process.env },
+);
+
+const publicUrl = `${SITE_ORIGIN}${BASE_PATH}/img/${destFilename}`;
+
+console.log(`Purging edge cache for ${publicUrl}`);
+const purgeResponse = await fetch(
+ `https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ files: [publicUrl] }),
+ },
+);
+
+const purgeResult = await purgeResponse.json();
+if (!purgeResponse.ok || !purgeResult.success) {
+ console.error("Cache purge failed:", JSON.stringify(purgeResult, null, 2));
+ process.exit(1);
+}
+
+console.log(`Done. Live at ${publicUrl}`);
+console.log(`Reference it from MDX as: `);
diff --git a/source.config.ts b/source.config.ts
index 1965eca..2bc3cc3 100644
--- a/source.config.ts
+++ b/source.config.ts
@@ -4,6 +4,7 @@ import {
frontmatterSchema,
metaSchema,
} from "fumadocs-mdx/config";
+import { remarkDocImages } from "./src/lib/remark-doc-images";
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections
@@ -22,6 +23,16 @@ export const docs = defineDocs({
export default defineConfig({
mdxOptions: {
- // MDX options
+ // Content images are served at runtime from R2 via worker/index.ts
+ // (see scripts/README.md), not committed to this repo. fumadocs-mdx's
+ // remarkImage plugin needs the file on local disk at build time --
+ // exactly what we're avoiding -- so it's disabled here. Authors keep
+ // writing plain markdown image syntax with whatever relative path they
+ // always used (`../images/x.png`); remarkDocImages below resolves that
+ // to the R2-proxy URL at compile time instead (pure path math, no
+ // manifest), and src/mdx-components.tsx's `img:` override renders it as
+ // a plain, zoomable .
+ remarkImageOptions: false,
+ remarkPlugins: [remarkDocImages],
},
});
diff --git a/src/lib/remark-doc-images.ts b/src/lib/remark-doc-images.ts
new file mode 100644
index 0000000..bc7cf38
--- /dev/null
+++ b/src/lib/remark-doc-images.ts
@@ -0,0 +1,50 @@
+import { dirname, join, normalize, relative } from "node:path";
+
+// Doc content images live in the shared R2 bucket (see worker/index.ts)
+// instead of content/docs/images. Authors keep writing plain markdown image
+// syntax with the same paths they always used -- this plugin rewrites each
+// image's `url` at compile time (pure path math, no image bytes needed)
+// into the literal "/docs/hardware/img/..." URL the Worker serves, so
+// nothing about the authoring experience changes.
+//
+// - Relative paths ("./images/x.png", "../images/x.png", or bare
+// "images/x.png") resolve against the source .mdx file's own location,
+// same as markdown always works.
+// - Full external URLs (http://, https://) are left untouched.
+const CONTENT_IMAGES_ROOT = join(process.cwd(), "content/docs/images");
+const IMG_ROUTE_PREFIX = "/docs/hardware/img";
+
+// Minimal structural types for what this plugin touches -- avoids pulling in
+// `@types/mdast`/`vfile` as direct dependencies for two fields.
+interface MdastNode {
+ type?: string;
+ url?: string;
+ children?: MdastNode[];
+}
+interface CompileFile {
+ path: string;
+}
+
+function walk(node: MdastNode, visitor: (node: MdastNode) => void) {
+ if (node.type === "image") visitor(node);
+ if (Array.isArray(node.children)) {
+ for (const child of node.children) walk(child, visitor);
+ }
+}
+
+export function remarkDocImages() {
+ return (tree: MdastNode, file: CompileFile) => {
+ walk(tree, (node) => {
+ if (typeof node.url !== "string" || node.url.length === 0) return;
+ if (/^https?:\/\//.test(node.url)) return; // external, leave alone
+ if (node.url.startsWith(IMG_ROUTE_PREFIX)) return; // already resolved
+
+ const fileDir = dirname(file.path);
+ const absolute = normalize(join(fileDir, node.url));
+ const relativeToImages = relative(CONTENT_IMAGES_ROOT, absolute)
+ .split("\\")
+ .join("/");
+ node.url = `${IMG_ROUTE_PREFIX}/${relativeToImages}`;
+ });
+ };
+}
diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx
index 3902aff..c1314da 100644
--- a/src/mdx-components.tsx
+++ b/src/mdx-components.tsx
@@ -2,7 +2,7 @@ import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock";
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
-import { assetPath } from "@/lib/base-path";
+import type { ComponentPropsWithoutRef } from "react";
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
@@ -12,10 +12,25 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
{props.children}
),
- img: (props: React.ComponentProps) => {
- const src =
- typeof props.src === "string" ? assetPath(props.src) : props.src;
- return ;
+ // Content images (content/docs/**/*.mdx) are no longer bundled by
+ // Next.js's image pipeline -- see source.config.ts: remarkImageOptions
+ // is disabled and remarkDocImages resolves each image's relative path
+ // to its R2-proxied "/docs/hardware/img/..." URL at compile time.
+ // Rendered as a plain, zoomable -- no next/image, no
+ // width/height needed, so there's nothing to keep in sync when images
+ // change.
+ img: (props: ComponentPropsWithoutRef<"img">) => {
+ if (typeof props.src !== "string") return null;
+ const { src, alt, ...rest } = props;
+ return (
+ // src/alt passed here too, not just to the inner : ImageZoom's
+ // zoomed-in view reads its image from these props directly, not
+ // from `children` -- omitting them renders a blank zoomed-in image
+ // even though the inline thumbnail (via children) looks correct.
+
+
+
+ );
},
...components,
};
diff --git a/tsconfig.json b/tsconfig.json
index 057b290..3f2fc89 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -31,5 +31,11 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": ["node_modules"]
+ "exclude": [
+ "node_modules",
+ // Runs directly on the Workers runtime via wrangler's own bundler/type
+ // scope, not part of the Next.js app — keep it out of `tsc --noEmit`
+ // here so it isn't checked against `lib: ["dom", ...]`.
+ "worker"
+ ]
}
diff --git a/worker/index.ts b/worker/index.ts
new file mode 100644
index 0000000..e964e48
--- /dev/null
+++ b/worker/index.ts
@@ -0,0 +1,133 @@
+// Cloudflare Worker entry point.
+//
+// This site is a fully static Next.js export (`output: "export"` in
+// next.config.mjs) served by Cloudflare's native static assets (see
+// `assets` in wrangler.jsonc) — there is no Next.js server runtime in
+// production. That means neither @cloudflare/next-on-pages nor
+// @opennextjs/cloudflare apply here: there's no running Next.js request
+// handler on Cloudflare to reach a binding from. This file is the smallest
+// possible layer on top of that: it serves everything through the ASSETS
+// binding exactly as before, except requests for content images, which it
+// answers directly from the shared R2 bucket. That's the only reason this
+// repo has a `main` Worker script at all — see scripts/README.md.
+//
+// Docs content images used to be committed to git, co-located with their
+// MDX files under content/docs/images/, and referenced with relative
+// markdown paths (e.g. `../images/s0.png`). Next's static-export bundler
+// resolved those at build time into content-hashed files under
+// `_next/static/media/`, which meant the image bytes had to be physically
+// present in the repo just to run `next build`. That's incompatible with
+// "stop committing images to git", so content images are now referenced by
+// a stable absolute path instead (`/img/`, resolved to
+// `/docs/hardware/img/` via `assetPath()` in src/lib/base-path.ts)
+// and served by this route.
+
+interface R2ObjectBody {
+ body: ReadableStream;
+ size: number;
+ httpEtag: string;
+ writeHttpMetadata(headers: Headers): void;
+}
+
+interface R2Bucket {
+ get(key: string): Promise;
+}
+
+interface Fetcher {
+ fetch(request: Request): Promise;
+}
+
+interface Env {
+ ASSETS: Fetcher;
+ IMAGES_BUCKET: R2Bucket;
+}
+
+// Minimal structural types for the Workers Cache API -- avoids depending on
+// the gitignored, wrangler-generated worker-configuration.d.ts (pnpm run
+// build never regenerates it, only the separate types:check script does).
+interface CFCache {
+ match(request: Request): Promise;
+ put(request: Request, response: Response): Promise;
+}
+interface CFCacheStorage {
+ readonly default: CFCache;
+}
+interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+}
+
+// Matches next.config.mjs's BASE_PATH — this Worker has no access to that
+// module (it isn't part of the Next.js build), so it's repeated here.
+const IMG_ROUTE_PREFIX = "/docs/hardware/img/";
+
+// Shared bucket ("websites-images") holds assets for multiple properties;
+// this prefix keeps this site's objects from colliding with theirs.
+const R2_KEY_PREFIX = "hardware-docs";
+
+function notFound(): Response {
+ return new Response("Not found", {
+ status: 404,
+ headers: { "cache-control": "no-store" },
+ });
+}
+
+async function handleImageProxy(
+ request: Request,
+ env: Env,
+ ctx: ExecutionContext,
+): Promise {
+ const url = new URL(request.url);
+ const key = url.pathname.slice(IMG_ROUTE_PREFIX.length);
+ if (!key) return notFound();
+
+ // env.IMAGES_BUCKET.get() is an R2 binding call, not an HTTP subrequest --
+ // it never touches Cloudflare's HTTP cache. Without explicitly writing the
+ // response into the Cache API, every request (from every visitor, at
+ // every edge location) would re-read from R2, no matter what
+ // Cache-Control header gets set on the returned Response. Using the
+ // request's own URL (unmodified) as the cache key keeps this purgeable by
+ // the existing purge-by-URL call in scripts/publish-image.mjs.
+ const cache = (caches as unknown as CFCacheStorage).default;
+ const cacheKey = new Request(request.url, request);
+
+ const cached = await cache.match(cacheKey);
+ if (cached) return cached;
+
+ const object = await env.IMAGES_BUCKET.get(`${R2_KEY_PREFIX}/${key}`);
+ if (!object) return notFound();
+
+ const headers = new Headers();
+ object.writeHttpMetadata(headers);
+ headers.set("etag", object.httpEtag);
+ headers.set("content-length", String(object.size));
+ // Browser TTL long enough to skip most repeat-visit requests, short
+ // enough to self-heal within the hour if a purge is ever missed. Edge TTL
+ // is effectively unbounded -- scripts/publish-image.mjs purges it
+ // explicitly and immediately on every upload, so there's no benefit to a
+ // shorter one, and every edge location that has ever served an image now
+ // actually caches it (see the Cache API use above).
+ headers.set("cache-control", "public, max-age=3600, s-maxage=31536000");
+
+ const response = new Response(object.body, { headers });
+ ctx.waitUntil(cache.put(cacheKey, response.clone()));
+ return response;
+}
+
+export default {
+ async fetch(
+ request: Request,
+ env: Env,
+ ctx: ExecutionContext,
+ ): Promise {
+ const url = new URL(request.url);
+ if (url.pathname.startsWith(IMG_ROUTE_PREFIX)) {
+ return handleImageProxy(request, env, ctx);
+ }
+
+ // run_worker_first defaults to false, so in production this Worker only
+ // runs when no static asset matched the request path already — this
+ // fetch() is here for local `wrangler dev` parity and clarity, not to
+ // duplicate work the platform already does.
+ return env.ASSETS.fetch(request);
+ },
+};
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 2cedccd..d26ad4e 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -1,12 +1,20 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "hardware-docs",
+ "main": "./worker/index.ts",
"compatibility_date": "2026-05-29",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"observability": {
"enabled": true
},
"assets": {
- "directory": "./out"
- }
+ "directory": "./out",
+ "binding": "ASSETS"
+ },
+ "r2_buckets": [
+ {
+ "binding": "IMAGES_BUCKET",
+ "bucket_name": "websites-images"
+ }
+ ]
}