diff --git a/docs/.gitignore b/docs/.gitignore
index 8a11fd9b..10c920f4 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -10,6 +10,7 @@
/out/
/build
*.tsbuildinfo
+/.wrangler/
# misc
.DS_Store
@@ -24,3 +25,12 @@ yarn-error.log*
.env*.local
.vercel
next-env.d.ts
+
+# doc images -- served from R2 (see worker/index.ts), not committed here.
+# Fine to drop a file here temporarily for a `next dev` preview; it just
+# won't be tracked, and scripts/nest-static-export.mjs strips it from any
+# build output before deploy either way.
+/public/img/
+
+# holds a real Cloudflare API token -- see scripts/README.md
+/scripts/.env.publish-image
diff --git a/docs/README.md b/docs/README.md
index b4897219..03626549 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,7 +19,8 @@ This site follows the same Cloudflare Workers static-assets pattern used by the
- **Next.js static export** - `next build` outputs static files to `out/`
- **Next.js `basePath`** - links and assets are generated under `/docs/atom`
-- **Post-build nesting** - `scripts/nest-static-export.mjs` moves the export under `out/docs/atom/` 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/atom/` so Cloudflare static assets can serve it from the route prefix
+- **Doc images via R2** - `worker/index.ts` is a small Worker in front of the static assets that serves `/docs/atom/img/...` from a shared Cloudflare R2 bucket instead of the repo, so images can be updated and cache-purged without a rebuild. It only runs as a fallback for requests that don't match a static asset (`run_worker_first: false`, the default) - every actual page is still served directly from `out/` with no Worker involved. See [`scripts/README.md`](./scripts/README.md) for the full picture and the publishing workflow.
### Cloudflare Build Settings
@@ -57,9 +58,14 @@ flowchart LR
end
subgraph Runtime_Request_Flow
- U[Browser request] --> H[Cloudflare static asset route]
- H --> J[Static asset lookup]
+ U[Browser request] --> H{Matches a static asset?}
+ H -->|yes| J[Serve from out/]
J --> U
+ H -->|no, e.g. /docs/atom/img/...| K[worker/index.ts]
+ K -->|img path| L[R2: websites-images/atom-docs/...]
+ K -->|anything else| M[404 via assets binding]
+ L --> U
+ M --> U
end
```
@@ -73,10 +79,13 @@ NEXT_PUBLIC_BASE_URL=https://www.absmach.eu/docs/atom
## Project Structure
-| Path | Description |
-| -------------------------------- | --------------------------------------- |
-| `app/[[...slug]]/page.tsx` | Docs page renderer |
-| `content/docs` | MDX source files |
-| `lib/source.ts` | Fumadocs source adapter |
-| `scripts/nest-static-export.mjs` | Moves static export under `/docs/atom` |
-| `wrangler.jsonc` | Cloudflare Workers static-assets config |
+| Path | Description |
+| ---------------------------------- | -------------------------------------------------------------------------------- |
+| `app/[[...slug]]/page.tsx` | Docs page renderer |
+| `content/docs` | MDX source files |
+| `lib/source.ts` | Fumadocs source adapter |
+| `components/doc-image.tsx` | Renders doc images as a plain, zoomable `
` (no `next/image`, no manifest) |
+| `worker/index.ts` | Serves `/docs/atom/img/...` from R2, falls back to static assets otherwise |
+| `scripts/nest-static-export.mjs` | Moves static export under `/docs/atom`, strips any local `img/` from it |
+| `scripts/publish-image.mjs` | Maintainer-only: uploads a doc image to R2 and purges its cache |
+| `wrangler.jsonc` | Cloudflare Workers static-assets + Worker + R2 binding config |
diff --git a/docs/app/[[...slug]]/page.tsx b/docs/app/[[...slug]]/page.tsx
index f5e2b027..f63bc429 100644
--- a/docs/app/[[...slug]]/page.tsx
+++ b/docs/app/[[...slug]]/page.tsx
@@ -13,6 +13,7 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import type { Metadata } from 'next';
import { Mermaid } from '@/components/mermaid';
+import { DocImage } from '@/components/doc-image';
const mdxComponents = {
...defaultMdxComponents,
@@ -24,6 +25,10 @@ const mdxComponents = {
Mermaid,
Tab,
Tabs,
+ // Overrides defaultMdxComponents' next/image-backed img -- doc images are
+ // served from R2 via a same-origin proxy, not bundled by next/image. See
+ // components/doc-image.tsx.
+ img: DocImage,
};
export default async function Page({
diff --git a/docs/components/doc-image.tsx b/docs/components/doc-image.tsx
new file mode 100644
index 00000000..360090b7
--- /dev/null
+++ b/docs/components/doc-image.tsx
@@ -0,0 +1,39 @@
+import { ImageZoom } from "fumadocs-ui/components/image-zoom";
+import type { ImgHTMLAttributes } from "react";
+
+// Doc content images (content/docs/**/*.mdx) are no longer bundled by
+// Next.js's image pipeline (see source.config.ts: remarkImageOptions is
+// disabled). They're stored in the shared Cloudflare R2 bucket and served
+// same-origin at their usual "/img/..." path -- see docs/wrangler.jsonc and
+// worker/index.ts. Rendered as a plain, zoomable
-- no next/image, no
+// width/height needed, so there's nothing to keep in sync when images
+// change.
+const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
+
+export function DocImage({
+ src,
+ alt,
+ className,
+ ...props
+}: ImgHTMLAttributes) {
+ if (typeof src !== "string") return null;
+
+ const resolvedSrc = src.startsWith("/") ? `${BASE_PATH}${src}` : src;
+
+ 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.
+
+ {/* biome-ignore lint/performance/noImgElement: doc content images are served from R2, not Next's image pipeline */}
+
+
+ );
+}
diff --git a/docs/mdx-components.tsx b/docs/mdx-components.tsx
index 7536859d..7c09e829 100644
--- a/docs/mdx-components.tsx
+++ b/docs/mdx-components.tsx
@@ -5,6 +5,7 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import type { MDXComponents } from 'mdx/types';
import { Mermaid } from './components/mermaid';
+import { DocImage } from './components/doc-image';
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
@@ -17,6 +18,9 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
Mermaid,
Tab,
Tabs,
+ // Overrides defaultComponents' next/image-backed img -- see
+ // components/doc-image.tsx for why.
+ img: DocImage,
...components,
};
}
diff --git a/docs/package.json b/docs/package.json
index c98a5f01..ba22a669 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -7,8 +7,11 @@
"dev": "next dev --turbopack",
"build": "next build && node scripts/nest-static-export.mjs",
"start": "serve out",
- "deploy": "pnpm run build && wrangler deploy",
- "upload": "pnpm run build && wrangler versions upload"
+ "typecheck:worker": "tsc --noEmit -p worker/tsconfig.json",
+ "deploy": "pnpm run build && pnpm run typecheck:worker && wrangler deploy",
+ "upload": "pnpm run build && pnpm run typecheck:worker && wrangler versions upload",
+ "preview": "pnpm run build && wrangler dev",
+ "publish-image": "node scripts/publish-image.mjs"
},
"dependencies": {
"@orama/orama": "^3.1.18",
@@ -21,6 +24,7 @@
"react-dom": "^19"
},
"devDependencies": {
+ "@cloudflare/workers-types": "^4",
"@types/mdx": "^2.0.13",
"@types/node": "^22",
"@types/react": "^19",
@@ -32,5 +36,12 @@
"typescript": "^5",
"wrangler": "^4.95.0"
},
- "packageManager": "pnpm@10.33.0"
+ "packageManager": "pnpm@10.33.0",
+ "pnpm": {
+ "overrides": {
+ "js-yaml": "^4.3.1",
+ "sharp": "^0.35.3",
+ "postcss": "^8.5.18"
+ }
+ }
}
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
index 0a18b189..600f2ea3 100644
--- a/docs/pnpm-lock.yaml
+++ b/docs/pnpm-lock.yaml
@@ -5,10 +5,9 @@ settings:
excludeLinksFromLockfile: false
overrides:
- dompurify: 3.4.12
- js-yaml: 4.3.0
- postcss: 8.5.18
- sharp: 0.35.3
+ js-yaml: ^4.3.1
+ sharp: ^0.35.3
+ postcss: ^8.5.18
importers:
@@ -39,6 +38,9 @@ importers:
specifier: ^19
version: 19.2.5(react@19.2.5)
devDependencies:
+ '@cloudflare/workers-types':
+ specifier: ^4
+ version: 4.20260702.1
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
@@ -55,7 +57,7 @@ importers:
specifier: ^10
version: 10.5.0(postcss@8.5.18)
postcss:
- specifier: 8.5.18
+ specifier: ^8.5.18
version: 8.5.18
serve:
specifier: ^14.2.5
@@ -68,7 +70,7 @@ importers:
version: 5.9.3
wrangler:
specifier: ^4.95.0
- version: 4.98.0(@types/node@22.19.17)
+ version: 4.98.0(@cloudflare/workers-types@4.20260702.1)(@types/node@22.19.17)
packages:
@@ -128,6 +130,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@cloudflare/workers-types@4.20260702.1':
+ resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==}
+
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -1351,7 +1356,7 @@ packages:
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
- postcss: 8.5.18
+ postcss: ^8.5.18
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -2048,8 +2053,8 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
- js-yaml@4.3.0:
- resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
+ js-yaml@4.3.1:
+ resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
json-schema-traverse@1.0.0:
@@ -2439,20 +2444,20 @@ packages:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
peerDependencies:
- postcss: 8.5.18
+ postcss: ^8.5.18
postcss-js@4.1.0:
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
engines: {node: ^12 || ^14 || >= 16}
peerDependencies:
- postcss: 8.5.18
+ postcss: ^8.5.18
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
- postcss: 8.5.18
+ postcss: ^8.5.18
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
@@ -2469,7 +2474,7 @@ packages:
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
peerDependencies:
- postcss: 8.5.18
+ postcss: ^8.5.18
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
@@ -2977,6 +2982,8 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20260603.1':
optional: true
+ '@cloudflare/workers-types@4.20260702.1': {}
+
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -4609,7 +4616,7 @@ snapshots:
esbuild: 0.25.12
estree-util-value-to-estree: 3.5.0
fumadocs-core: 14.7.7(@types/react@19.2.14)(next@15.5.22(@types/node@22.19.17)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
- js-yaml: 4.3.0
+ js-yaml: 4.3.1
lru-cache: 11.3.5
picocolors: 1.1.1
remark-mdx: 3.1.1
@@ -4809,7 +4816,7 @@ snapshots:
jiti@1.21.7: {}
- js-yaml@4.3.0:
+ js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -6014,7 +6021,7 @@ snapshots:
'@cloudflare/workerd-linux-arm64': 1.20260603.1
'@cloudflare/workerd-windows-64': 1.20260603.1
- wrangler@4.98.0(@types/node@22.19.17):
+ wrangler@4.98.0(@cloudflare/workers-types@4.20260702.1)(@types/node@22.19.17):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1)
@@ -6025,6 +6032,7 @@ snapshots:
unenv: 2.0.0-rc.24
workerd: 1.20260603.1
optionalDependencies:
+ '@cloudflare/workers-types': 4.20260702.1
fsevents: 2.3.3
transitivePeerDependencies:
- '@types/node'
diff --git a/docs/public/img/user-guide/account/access-token-create-highlight.png b/docs/public/img/user-guide/account/access-token-create-highlight.png
deleted file mode 100644
index 4e5068f6..00000000
Binary files a/docs/public/img/user-guide/account/access-token-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/account/access-token-form.png b/docs/public/img/user-guide/account/access-token-form.png
deleted file mode 100644
index cc309128..00000000
Binary files a/docs/public/img/user-guide/account/access-token-form.png and /dev/null differ
diff --git a/docs/public/img/user-guide/account/access-token-list.png b/docs/public/img/user-guide/account/access-token-list.png
deleted file mode 100644
index 819b4f00..00000000
Binary files a/docs/public/img/user-guide/account/access-token-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/account/account-menu.png b/docs/public/img/user-guide/account/account-menu.png
deleted file mode 100644
index 1f19cc2d..00000000
Binary files a/docs/public/img/user-guide/account/account-menu.png and /dev/null differ
diff --git a/docs/public/img/user-guide/account/profile-page.png b/docs/public/img/user-guide/account/profile-page.png
deleted file mode 100644
index 92301f2d..00000000
Binary files a/docs/public/img/user-guide/account/profile-page.png and /dev/null differ
diff --git a/docs/public/img/user-guide/account/theme-menu.png b/docs/public/img/user-guide/account/theme-menu.png
deleted file mode 100644
index 794f0449..00000000
Binary files a/docs/public/img/user-guide/account/theme-menu.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/action-applicability-create-dialog.png b/docs/public/img/user-guide/actions/action-applicability-create-dialog.png
deleted file mode 100644
index 8d4b73ff..00000000
Binary files a/docs/public/img/user-guide/actions/action-applicability-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/action-applicability-create-filled.png b/docs/public/img/user-guide/actions/action-applicability-create-filled.png
deleted file mode 100644
index a4b9aa21..00000000
Binary files a/docs/public/img/user-guide/actions/action-applicability-create-filled.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/action-applicability-list.png b/docs/public/img/user-guide/actions/action-applicability-list.png
deleted file mode 100644
index 4c230e50..00000000
Binary files a/docs/public/img/user-guide/actions/action-applicability-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/action-inspect.png b/docs/public/img/user-guide/actions/action-inspect.png
deleted file mode 100644
index ae9dcb92..00000000
Binary files a/docs/public/img/user-guide/actions/action-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/actions-create-dialog.png b/docs/public/img/user-guide/actions/actions-create-dialog.png
deleted file mode 100644
index 723d2d11..00000000
Binary files a/docs/public/img/user-guide/actions/actions-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/actions-list.png b/docs/public/img/user-guide/actions/actions-list.png
deleted file mode 100644
index 7e766eea..00000000
Binary files a/docs/public/img/user-guide/actions/actions-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/assignment-guardrails-create-dialog.png b/docs/public/img/user-guide/actions/assignment-guardrails-create-dialog.png
deleted file mode 100644
index d72d3dc7..00000000
Binary files a/docs/public/img/user-guide/actions/assignment-guardrails-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/actions/assignment-guardrails-list.png b/docs/public/img/user-guide/actions/assignment-guardrails-list.png
deleted file mode 100644
index 74590975..00000000
Binary files a/docs/public/img/user-guide/actions/assignment-guardrails-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/audit/audit-export-csv-dialog.png b/docs/public/img/user-guide/audit/audit-export-csv-dialog.png
deleted file mode 100644
index 777f1c8e..00000000
Binary files a/docs/public/img/user-guide/audit/audit-export-csv-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/audit/audit-export-highlight.png b/docs/public/img/user-guide/audit/audit-export-highlight.png
deleted file mode 100644
index 29cf50b0..00000000
Binary files a/docs/public/img/user-guide/audit/audit-export-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/audit/audit-inspect.png b/docs/public/img/user-guide/audit/audit-inspect.png
deleted file mode 100644
index f82f60f8..00000000
Binary files a/docs/public/img/user-guide/audit/audit-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/audit/audit-list.png b/docs/public/img/user-guide/audit/audit-list.png
deleted file mode 100644
index 85744d79..00000000
Binary files a/docs/public/img/user-guide/audit/audit-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/authorization/authz-decision-allowed.png b/docs/public/img/user-guide/authorization/authz-decision-allowed.png
deleted file mode 100644
index c4650379..00000000
Binary files a/docs/public/img/user-guide/authorization/authz-decision-allowed.png and /dev/null differ
diff --git a/docs/public/img/user-guide/authorization/authz-decision-denied.png b/docs/public/img/user-guide/authorization/authz-decision-denied.png
deleted file mode 100644
index 344accfb..00000000
Binary files a/docs/public/img/user-guide/authorization/authz-decision-denied.png and /dev/null differ
diff --git a/docs/public/img/user-guide/authorization/authz-explain-highlight.png b/docs/public/img/user-guide/authorization/authz-explain-highlight.png
deleted file mode 100644
index e14a3cea..00000000
Binary files a/docs/public/img/user-guide/authorization/authz-explain-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/authorization/authz-request-filled.png b/docs/public/img/user-guide/authorization/authz-request-filled.png
deleted file mode 100644
index 67918098..00000000
Binary files a/docs/public/img/user-guide/authorization/authz-request-filled.png and /dev/null differ
diff --git a/docs/public/img/user-guide/dashboard/context-switcher.png b/docs/public/img/user-guide/dashboard/context-switcher.png
deleted file mode 100644
index abea211d..00000000
Binary files a/docs/public/img/user-guide/dashboard/context-switcher.png and /dev/null differ
diff --git a/docs/public/img/user-guide/dashboard/dashboard-overview.png b/docs/public/img/user-guide/dashboard/dashboard-overview.png
deleted file mode 100644
index 7afe8707..00000000
Binary files a/docs/public/img/user-guide/dashboard/dashboard-overview.png and /dev/null differ
diff --git a/docs/public/img/user-guide/developer/endpoint-inspect.png b/docs/public/img/user-guide/developer/endpoint-inspect.png
deleted file mode 100644
index ea45b4af..00000000
Binary files a/docs/public/img/user-guide/developer/endpoint-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/developer/endpoints-create-filled.png b/docs/public/img/user-guide/developer/endpoints-create-filled.png
deleted file mode 100644
index f1218de2..00000000
Binary files a/docs/public/img/user-guide/developer/endpoints-create-filled.png and /dev/null differ
diff --git a/docs/public/img/user-guide/developer/endpoints-list-empty.png b/docs/public/img/user-guide/developer/endpoints-list-empty.png
deleted file mode 100644
index 756ccd04..00000000
Binary files a/docs/public/img/user-guide/developer/endpoints-list-empty.png and /dev/null differ
diff --git a/docs/public/img/user-guide/developer/endpoints-templates.png b/docs/public/img/user-guide/developer/endpoints-templates.png
deleted file mode 100644
index 6505765d..00000000
Binary files a/docs/public/img/user-guide/developer/endpoints-templates.png and /dev/null differ
diff --git a/docs/public/img/user-guide/developer/playground-curl-tab.png b/docs/public/img/user-guide/developer/playground-curl-tab.png
deleted file mode 100644
index 01e18392..00000000
Binary files a/docs/public/img/user-guide/developer/playground-curl-tab.png and /dev/null differ
diff --git a/docs/public/img/user-guide/developer/playground-run-response.png b/docs/public/img/user-guide/developer/playground-run-response.png
deleted file mode 100644
index dbb41651..00000000
Binary files a/docs/public/img/user-guide/developer/playground-run-response.png and /dev/null differ
diff --git a/docs/public/img/user-guide/developer/playground.png b/docs/public/img/user-guide/developer/playground.png
deleted file mode 100644
index cc1218e8..00000000
Binary files a/docs/public/img/user-guide/developer/playground.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/direct-policies-list-create-highlight.png b/docs/public/img/user-guide/direct-policies/direct-policies-list-create-highlight.png
deleted file mode 100644
index 69e5e5c3..00000000
Binary files a/docs/public/img/user-guide/direct-policies/direct-policies-list-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/direct-policies-list-empty.png b/docs/public/img/user-guide/direct-policies/direct-policies-list-empty.png
deleted file mode 100644
index 9173ea39..00000000
Binary files a/docs/public/img/user-guide/direct-policies/direct-policies-list-empty.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/direct-policies-list-populated.png b/docs/public/img/user-guide/direct-policies/direct-policies-list-populated.png
deleted file mode 100644
index ba3cb837..00000000
Binary files a/docs/public/img/user-guide/direct-policies/direct-policies-list-populated.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/direct-policy-inspect.png b/docs/public/img/user-guide/direct-policies/direct-policy-inspect.png
deleted file mode 100644
index 8d313ee0..00000000
Binary files a/docs/public/img/user-guide/direct-policies/direct-policy-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/dp-wizard-step1-tenant.png b/docs/public/img/user-guide/direct-policies/dp-wizard-step1-tenant.png
deleted file mode 100644
index c02d16c3..00000000
Binary files a/docs/public/img/user-guide/direct-policies/dp-wizard-step1-tenant.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/dp-wizard-step2-subject.png b/docs/public/img/user-guide/direct-policies/dp-wizard-step2-subject.png
deleted file mode 100644
index e4432454..00000000
Binary files a/docs/public/img/user-guide/direct-policies/dp-wizard-step2-subject.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/dp-wizard-step3-permblock.png b/docs/public/img/user-guide/direct-policies/dp-wizard-step3-permblock.png
deleted file mode 100644
index f07d3f66..00000000
Binary files a/docs/public/img/user-guide/direct-policies/dp-wizard-step3-permblock.png and /dev/null differ
diff --git a/docs/public/img/user-guide/direct-policies/dp-wizard-step4-review.png b/docs/public/img/user-guide/direct-policies/dp-wizard-step4-review.png
deleted file mode 100644
index a9c94bb5..00000000
Binary files a/docs/public/img/user-guide/direct-policies/dp-wizard-step4-review.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entities-create-dialog.png b/docs/public/img/user-guide/entities/entities-create-dialog.png
deleted file mode 100644
index 4a5ca97b..00000000
Binary files a/docs/public/img/user-guide/entities/entities-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entities-kind-options.png b/docs/public/img/user-guide/entities/entities-kind-options.png
deleted file mode 100644
index 78feed32..00000000
Binary files a/docs/public/img/user-guide/entities/entities-kind-options.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entities-list-create-highlight.png b/docs/public/img/user-guide/entities/entities-list-create-highlight.png
deleted file mode 100644
index 2952b61e..00000000
Binary files a/docs/public/img/user-guide/entities/entities-list-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entities-list-populated.png b/docs/public/img/user-guide/entities/entities-list-populated.png
deleted file mode 100644
index 13ac50ee..00000000
Binary files a/docs/public/img/user-guide/entities/entities-list-populated.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entity-add-api-key.png b/docs/public/img/user-guide/entities/entity-add-api-key.png
deleted file mode 100644
index 975d856f..00000000
Binary files a/docs/public/img/user-guide/entities/entity-add-api-key.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entity-add-password.png b/docs/public/img/user-guide/entities/entity-add-password.png
deleted file mode 100644
index 567566f6..00000000
Binary files a/docs/public/img/user-guide/entities/entity-add-password.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entity-audit-logs-tab.png b/docs/public/img/user-guide/entities/entity-audit-logs-tab.png
deleted file mode 100644
index a5dee6ac..00000000
Binary files a/docs/public/img/user-guide/entities/entity-audit-logs-tab.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entity-check-authorization-highlight.png b/docs/public/img/user-guide/entities/entity-check-authorization-highlight.png
deleted file mode 100644
index 2cf454e4..00000000
Binary files a/docs/public/img/user-guide/entities/entity-check-authorization-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entity-credentials-list.png b/docs/public/img/user-guide/entities/entity-credentials-list.png
deleted file mode 100644
index b87943f8..00000000
Binary files a/docs/public/img/user-guide/entities/entity-credentials-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entity-inspect-details.png b/docs/public/img/user-guide/entities/entity-inspect-details.png
deleted file mode 100644
index a88dc346..00000000
Binary files a/docs/public/img/user-guide/entities/entity-inspect-details.png and /dev/null differ
diff --git a/docs/public/img/user-guide/entities/entity-issue-certificate.png b/docs/public/img/user-guide/entities/entity-issue-certificate.png
deleted file mode 100644
index 690e4d80..00000000
Binary files a/docs/public/img/user-guide/entities/entity-issue-certificate.png and /dev/null differ
diff --git a/docs/public/img/user-guide/groups/group-inspect-empty-members.png b/docs/public/img/user-guide/groups/group-inspect-empty-members.png
deleted file mode 100644
index e6625d55..00000000
Binary files a/docs/public/img/user-guide/groups/group-inspect-empty-members.png and /dev/null differ
diff --git a/docs/public/img/user-guide/groups/group-inspect-members.png b/docs/public/img/user-guide/groups/group-inspect-members.png
deleted file mode 100644
index adf4de94..00000000
Binary files a/docs/public/img/user-guide/groups/group-inspect-members.png and /dev/null differ
diff --git a/docs/public/img/user-guide/groups/groups-create-dialog.png b/docs/public/img/user-guide/groups/groups-create-dialog.png
deleted file mode 100644
index 9b32089a..00000000
Binary files a/docs/public/img/user-guide/groups/groups-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/groups/groups-list-create-highlight.png b/docs/public/img/user-guide/groups/groups-list-create-highlight.png
deleted file mode 100644
index 13d5ef39..00000000
Binary files a/docs/public/img/user-guide/groups/groups-list-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/groups/groups-list-populated.png b/docs/public/img/user-guide/groups/groups-list-populated.png
deleted file mode 100644
index e4e3698c..00000000
Binary files a/docs/public/img/user-guide/groups/groups-list-populated.png and /dev/null differ
diff --git a/docs/public/img/user-guide/operations/signing-keys-list.png b/docs/public/img/user-guide/operations/signing-keys-list.png
deleted file mode 100644
index 096d1e48..00000000
Binary files a/docs/public/img/user-guide/operations/signing-keys-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/operations/signing-keys-rotate-dialog.png b/docs/public/img/user-guide/operations/signing-keys-rotate-dialog.png
deleted file mode 100644
index e23e7690..00000000
Binary files a/docs/public/img/user-guide/operations/signing-keys-rotate-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/operations/system-health.png b/docs/public/img/user-guide/operations/system-health.png
deleted file mode 100644
index 75be199b..00000000
Binary files a/docs/public/img/user-guide/operations/system-health.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/pb-wizard-step1-boundary.png b/docs/public/img/user-guide/permission-blocks/pb-wizard-step1-boundary.png
deleted file mode 100644
index 5cb7173a..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/pb-wizard-step1-boundary.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/pb-wizard-step2-scope-filled.png b/docs/public/img/user-guide/permission-blocks/pb-wizard-step2-scope-filled.png
deleted file mode 100644
index a6985ccc..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/pb-wizard-step2-scope-filled.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/pb-wizard-step2-scope-options.png b/docs/public/img/user-guide/permission-blocks/pb-wizard-step2-scope-options.png
deleted file mode 100644
index 733444f5..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/pb-wizard-step2-scope-options.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/pb-wizard-step3-actions.png b/docs/public/img/user-guide/permission-blocks/pb-wizard-step3-actions.png
deleted file mode 100644
index cd706088..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/pb-wizard-step3-actions.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/pb-wizard-step4-conditions.png b/docs/public/img/user-guide/permission-blocks/pb-wizard-step4-conditions.png
deleted file mode 100644
index ec0afaa8..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/pb-wizard-step4-conditions.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/pb-wizard-step5-review.png b/docs/public/img/user-guide/permission-blocks/pb-wizard-step5-review.png
deleted file mode 100644
index 0e03b315..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/pb-wizard-step5-review.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/permission-block-inspect.png b/docs/public/img/user-guide/permission-blocks/permission-block-inspect.png
deleted file mode 100644
index cf659430..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/permission-block-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/permission-blocks-list-create-highlight.png b/docs/public/img/user-guide/permission-blocks/permission-blocks-list-create-highlight.png
deleted file mode 100644
index cce93f9c..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/permission-blocks-list-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/permission-blocks/permission-blocks-list-populated.png b/docs/public/img/user-guide/permission-blocks/permission-blocks-list-populated.png
deleted file mode 100644
index da3e752a..00000000
Binary files a/docs/public/img/user-guide/permission-blocks/permission-blocks-list-populated.png and /dev/null differ
diff --git a/docs/public/img/user-guide/profiles/profile-inspect.png b/docs/public/img/user-guide/profiles/profile-inspect.png
deleted file mode 100644
index 8d41ef11..00000000
Binary files a/docs/public/img/user-guide/profiles/profile-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/profiles/profiles-create-dialog.png b/docs/public/img/user-guide/profiles/profiles-create-dialog.png
deleted file mode 100644
index d4cd8fa7..00000000
Binary files a/docs/public/img/user-guide/profiles/profiles-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/profiles/profiles-create-version-step.png b/docs/public/img/user-guide/profiles/profiles-create-version-step.png
deleted file mode 100644
index 276a41aa..00000000
Binary files a/docs/public/img/user-guide/profiles/profiles-create-version-step.png and /dev/null differ
diff --git a/docs/public/img/user-guide/profiles/profiles-list.png b/docs/public/img/user-guide/profiles/profiles-list.png
deleted file mode 100644
index a7f90dff..00000000
Binary files a/docs/public/img/user-guide/profiles/profiles-list.png and /dev/null differ
diff --git a/docs/public/img/user-guide/resources/resource-inspect.png b/docs/public/img/user-guide/resources/resource-inspect.png
deleted file mode 100644
index d45e3d80..00000000
Binary files a/docs/public/img/user-guide/resources/resource-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/resources/resources-create-dialog.png b/docs/public/img/user-guide/resources/resources-create-dialog.png
deleted file mode 100644
index eddb521c..00000000
Binary files a/docs/public/img/user-guide/resources/resources-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/resources/resources-list-create-highlight.png b/docs/public/img/user-guide/resources/resources-list-create-highlight.png
deleted file mode 100644
index 18481747..00000000
Binary files a/docs/public/img/user-guide/resources/resources-list-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/resources/resources-list-empty.png b/docs/public/img/user-guide/resources/resources-list-empty.png
deleted file mode 100644
index efdc6454..00000000
Binary files a/docs/public/img/user-guide/resources/resources-list-empty.png and /dev/null differ
diff --git a/docs/public/img/user-guide/roles/role-inspect.png b/docs/public/img/user-guide/roles/role-inspect.png
deleted file mode 100644
index 8e063dc1..00000000
Binary files a/docs/public/img/user-guide/roles/role-inspect.png and /dev/null differ
diff --git a/docs/public/img/user-guide/roles/role-wizard-step1-basics.png b/docs/public/img/user-guide/roles/role-wizard-step1-basics.png
deleted file mode 100644
index 927ff04a..00000000
Binary files a/docs/public/img/user-guide/roles/role-wizard-step1-basics.png and /dev/null differ
diff --git a/docs/public/img/user-guide/roles/role-wizard-step2-blocks.png b/docs/public/img/user-guide/roles/role-wizard-step2-blocks.png
deleted file mode 100644
index 5ca719d8..00000000
Binary files a/docs/public/img/user-guide/roles/role-wizard-step2-blocks.png and /dev/null differ
diff --git a/docs/public/img/user-guide/roles/role-wizard-step2-selected.png b/docs/public/img/user-guide/roles/role-wizard-step2-selected.png
deleted file mode 100644
index 273ea852..00000000
Binary files a/docs/public/img/user-guide/roles/role-wizard-step2-selected.png and /dev/null differ
diff --git a/docs/public/img/user-guide/roles/role-wizard-step3-review.png b/docs/public/img/user-guide/roles/role-wizard-step3-review.png
deleted file mode 100644
index 430698bc..00000000
Binary files a/docs/public/img/user-guide/roles/role-wizard-step3-review.png and /dev/null differ
diff --git a/docs/public/img/user-guide/roles/roles-list-create-highlight.png b/docs/public/img/user-guide/roles/roles-list-create-highlight.png
deleted file mode 100644
index 3fd77347..00000000
Binary files a/docs/public/img/user-guide/roles/roles-list-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/roles/roles-list-populated.png b/docs/public/img/user-guide/roles/roles-list-populated.png
deleted file mode 100644
index bfe4e7b2..00000000
Binary files a/docs/public/img/user-guide/roles/roles-list-populated.png and /dev/null differ
diff --git a/docs/public/img/user-guide/tenants/tenant-edit-dialog.png b/docs/public/img/user-guide/tenants/tenant-edit-dialog.png
deleted file mode 100644
index 0d666d28..00000000
Binary files a/docs/public/img/user-guide/tenants/tenant-edit-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/tenants/tenant-inspect-details.png b/docs/public/img/user-guide/tenants/tenant-inspect-details.png
deleted file mode 100644
index 2dd43060..00000000
Binary files a/docs/public/img/user-guide/tenants/tenant-inspect-details.png and /dev/null differ
diff --git a/docs/public/img/user-guide/tenants/tenant-inspect-members.png b/docs/public/img/user-guide/tenants/tenant-inspect-members.png
deleted file mode 100644
index 1c4eebcb..00000000
Binary files a/docs/public/img/user-guide/tenants/tenant-inspect-members.png and /dev/null differ
diff --git a/docs/public/img/user-guide/tenants/tenants-create-dialog.png b/docs/public/img/user-guide/tenants/tenants-create-dialog.png
deleted file mode 100644
index 3c1de2f3..00000000
Binary files a/docs/public/img/user-guide/tenants/tenants-create-dialog.png and /dev/null differ
diff --git a/docs/public/img/user-guide/tenants/tenants-list-create-highlight.png b/docs/public/img/user-guide/tenants/tenants-list-create-highlight.png
deleted file mode 100644
index 9b256f01..00000000
Binary files a/docs/public/img/user-guide/tenants/tenants-list-create-highlight.png and /dev/null differ
diff --git a/docs/public/img/user-guide/tenants/tenants-list-populated.png b/docs/public/img/user-guide/tenants/tenants-list-populated.png
deleted file mode 100644
index b83d0bd5..00000000
Binary files a/docs/public/img/user-guide/tenants/tenants-list-populated.png and /dev/null differ
diff --git a/docs/scripts/.env.publish-image.example b/docs/scripts/.env.publish-image.example
new file mode 100644
index 00000000..fa4df6f3
--- /dev/null
+++ b/docs/scripts/.env.publish-image.example
@@ -0,0 +1,10 @@
+# Copy to scripts/.env.publish-image (gitignored) and fill in the token.
+# Create the token under My Profile -> API Tokens -> Create Token -> Custom Token, with:
+# - Workers R2 Storage: Edit
+# - Zone -> Cache Purge -> Purge (scoped to the absmach.eu zone)
+CLOUDFLARE_API_TOKEN=
+
+# Not secret - the absmach.eu zone ID, visible on the domain's Overview page.
+# Same zone as the main absmach-website repo (this docs site is served at
+# https://www.absmach.eu/docs/atom, same domain).
+CLOUDFLARE_ZONE_ID=9cb2232dc0e21fbfabf9ce52b1834f56
diff --git a/docs/scripts/README.md b/docs/scripts/README.md
new file mode 100644
index 00000000..bc28f018
--- /dev/null
+++ b/docs/scripts/README.md
@@ -0,0 +1,160 @@
+# Publishing doc images (maintainers only)
+
+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`](../worker/index.ts), a small Worker that sits in front of this site's
+static assets.
+
+## Why a Worker exists here at all
+
+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.
+
+## Why doc images also needed an MDX change
+
+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/.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`](../components/doc-image.tsx) renders them as a plain, zoomable
+`
` (`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.
+
+## 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 `absmach.eu` zone
+
+ (If you already hold the token used for the main `absmach-website` repo's
+ `publish-image` script, it covers the same bucket and zone -- you can reuse it here
+ instead of creating a new one.)
+
+3. Paste the token into `CLOUDFLARE_API_TOKEN` in `scripts/.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).
+
+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.
+
+`scripts/.env.publish-image` is gitignored. Never commit it, never paste the token value into
+a PR, issue, or chat.
+
+## Publishing an image
+
+```bash
+pnpm run publish-image
+```
+
+`` must start with `img/` and match the path already written (or about to be
+written) into the MDX content, e.g.:
+
+```bash
+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:
+
+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 path, it overwrites the object in place and
+purges again -- that's the intended way to update an image without changing its URL.
+
+## Local development
+
+`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 -- run:
+
+```bash
+pnpm run preview # builds, then `wrangler dev`
+```
+
+By default this talks to a local simulated R2 bucket (empty unless you've seeded it with
+`wrangler r2 object put ... --local`). Add `"remote": true` to the `r2_buckets` binding in
+`wrangler.jsonc` temporarily if you want `wrangler dev` to read the real bucket instead.
+
+## Migrating the existing images (one-time, already done)
+
+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.
+
+## 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 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.
+
+## 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 now, but plain `pnpm run publish-image ` (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 MDX `src`), e.g.
+ `img/user-guide/roles/foo.png`, not `user-guide/roles/foo.png` or a full URL.
+- **`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 `absmach.eu` 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/atom-docs/ --remote --file=/tmp/check
+ ```
diff --git a/docs/scripts/nest-static-export.mjs b/docs/scripts/nest-static-export.mjs
index 7121b9e8..7cbd2c88 100644
--- a/docs/scripts/nest-static-export.mjs
+++ b/docs/scripts/nest-static-export.mjs
@@ -43,4 +43,18 @@ for (const entry of await fs.readdir(tempDir)) {
await fs.rmdir(tempDir);
+// Doc images are served from R2 via worker/index.ts, not from the static
+// export -- public/img is gitignored and normally won't exist at build
+// time, but strip it here too in case a locally-gitignored copy (kept
+// around for `next dev` previews) is still sitting in public/img when
+// someone runs a build. If it survived into out/, Cloudflare would serve
+// it directly as a static asset instead of falling through to the R2
+// proxy Worker, silently defeating the whole point of the migration
+// (stale content, no purge-on-update).
+const imgDir = path.join(nestedDir, "img");
+if (await pathExists(imgDir)) {
+ await fs.rm(imgDir, { recursive: true, force: true });
+ console.log(`Stripped ${path.join(basePath, "img")} from static export`);
+}
+
console.log(`Nested static export under out/${basePath}`);
diff --git a/docs/scripts/publish-image.mjs b/docs/scripts/publish-image.mjs
new file mode 100644
index 00000000..e15851f9
--- /dev/null
+++ b/docs/scripts/publish-image.mjs
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+// Maintainer-only. Uploads a doc 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 absmach.eu) and CLOUDFLARE_ZONE_ID.
+//
+// Usage:
+// pnpm run publish-image
+//
+// is the path used in MDX content, starting with "img/" to
+// match the route worker/index.ts serves it back on:
+// pnpm run publish-image ./roles-list.png img/user-guide/roles/roles-list-populated.png
+// -> referenced in MDX as /img/user-guide/roles/roles-list-populated.png
+// -> live at https://www.absmach.eu/docs/atom/img/user-guide/roles/roles-list-populated.png
+
+import { execFileSync } from "node:child_process";
+import { existsSync } from "node:fs";
+import { extname } from "node:path";
+import process from "node:process";
+
+const BUCKET_NAME = "websites-images";
+// This docs site is served under https://www.absmach.eu/docs/atom (see
+// README.md, app/sitemap.ts) -- same zone as the main absmach-website repo,
+// which is why CLOUDFLARE_ZONE_ID below matches that repo's.
+const SITE_ORIGIN = "https://www.absmach.eu";
+const BASE_PATH = "docs/atom";
+
+// Shared bucket ("websites-images") holds assets for multiple properties;
+// this prefix keeps atom-docs' objects from colliding with the others.
+// Keep in sync with worker/index.ts.
+const R2_KEY_PREFIX = "atom-docs";
+
+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",
+};
+
+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, publicPath] = cliArgs;
+
+if (!localFile || !publicPath) {
+ console.error(
+ "Usage: pnpm run publish-image \n" +
+ "Example: pnpm run publish-image ./roles-list.png img/user-guide/roles/roles-list-populated.png",
+ );
+ process.exit(1);
+}
+
+if (!existsSync(localFile)) {
+ console.error(`Local file not found: ${localFile}`);
+ process.exit(1);
+}
+
+const destKey = publicPath.replace(/^\/+/, "");
+if (!destKey.startsWith("img/") || destKey === "img/") {
+ console.error(`Destination must start with "img/" and include a path, got: ${destKey}`);
+ process.exit(1);
+}
+const restPath = destKey.slice("img/".length);
+
+const contentType = MIME_TYPES[extname(restPath).toLowerCase()];
+if (!contentType) {
+ console.error(`Unrecognized file extension for: ${destKey}`);
+ 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 the token.",
+ );
+ process.exit(1);
+}
+
+const objectPath = `${BUCKET_NAME}/${R2_KEY_PREFIX}/${restPath}`;
+
+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}/${destKey}`;
+
+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}`);
diff --git a/docs/source.config.ts b/docs/source.config.ts
index 8dc21074..c875500f 100644
--- a/docs/source.config.ts
+++ b/docs/source.config.ts
@@ -4,4 +4,15 @@ export const { docs, meta } = defineDocs({
dir: 'content/docs',
});
-export default defineConfig();
+export default defineConfig({
+ mdxOptions: {
+ // Doc images are served from R2 via a same-origin proxy route (see
+ // docs/worker/index.ts) rather than committed to public/img and bundled
+ // by next/image. The default remark-image plugin needs the source file
+ // on local disk (either to import it as a static asset, or just to
+ // probe its dimensions) -- disabling it keeps "/img/..." src strings in
+ // MDX content literal instead. Width/height come from
+ // lib/image-dimensions.json instead (see components/doc-image.tsx).
+ remarkImageOptions: false,
+ },
+});
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
index e66db53f..950ba9ef 100644
--- a/docs/tsconfig.json
+++ b/docs/tsconfig.json
@@ -20,5 +20,7 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ // worker/ runs in the Workers runtime (Cloudflare global types), not the
+ // Next.js/DOM one this tsconfig targets -- see worker/tsconfig.json.
+ "exclude": ["node_modules", "worker"]
}
diff --git a/docs/worker/index.ts b/docs/worker/index.ts
new file mode 100644
index 00000000..1496f77d
--- /dev/null
+++ b/docs/worker/index.ts
@@ -0,0 +1,87 @@
+// This site is a Next.js static export (see next.config.mjs: output:
+// "export") deployed as Cloudflare Workers static assets -- there is no
+// Next.js server runtime here, so no @cloudflare/next-on-pages or
+// @opennextjs/cloudflare adapter, and no per-request Next.js code path to
+// hang an R2 lookup off of. This tiny Worker sits in front of the static
+// assets (see wrangler.jsonc: "main" + "assets.binding") purely to serve
+// doc images from the shared R2 bucket instead of the git repo.
+//
+// Cloudflare's default asset routing (run_worker_first: false, the
+// default -- see wrangler.jsonc) serves a request directly from the
+// assets directory whenever a matching file exists, and only invokes this
+// Worker when nothing matches. Doc images are no longer part of the
+// static export output (scripts/nest-static-export.mjs strips
+// out/docs/atom/img/ as a defensive measure even though public/img/ is
+// gitignored and normally won't be present at build time), so every
+// request under the docs img path automatically falls through to here.
+// Everything else that reaches this Worker is a genuine 404, deferred to
+// the assets binding's own not-found handling.
+interface Env {
+ ASSETS: Fetcher;
+ IMAGES_BUCKET: R2Bucket;
+}
+
+// Matches the Next.js basePath ("/docs/atom", see next.config.mjs) plus
+// the literal "/img/..." convention doc content already uses in
+// content/docs/**/*.mdx (e.g. "").
+// Keep in sync with scripts/publish-image.mjs.
+const IMG_PREFIX = "/docs/atom/img/";
+
+// Shared bucket ("websites-images") holds assets for multiple properties;
+// this prefix keeps atom-docs' objects from colliding with the others.
+const R2_KEY_PREFIX = "atom-docs";
+
+function notFound(): Response {
+ return new Response("Not found", {
+ status: 404,
+ headers: { "content-type": "text/plain", "cache-control": "no-store" },
+ });
+}
+
+export default {
+ async fetch(
+ request: Request,
+ env: Env,
+ ctx: ExecutionContext,
+ ): Promise {
+ const url = new URL(request.url);
+
+ if (!url.pathname.startsWith(IMG_PREFIX)) {
+ return env.ASSETS.fetch(request);
+ }
+
+ // 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.default;
+ const cacheKey = new Request(request.url, request);
+
+ const cached = await cache.match(cacheKey);
+ if (cached) return cached;
+
+ const key = `${R2_KEY_PREFIX}/${url.pathname.slice(IMG_PREFIX.length)}`;
+ const object = await env.IMAGES_BUCKET.get(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;
+ },
+};
diff --git a/docs/worker/tsconfig.json b/docs/worker/tsconfig.json
new file mode 100644
index 00000000..d0c60a11
--- /dev/null
+++ b/docs/worker/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "lib": ["es2022"],
+ "module": "es2022",
+ "moduleResolution": "bundler",
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "isolatedModules": true
+ },
+ "include": ["./**/*.ts"]
+}
diff --git a/docs/wrangler.jsonc b/docs/wrangler.jsonc
index 2dcf3fd1..7b2d9b70 100644
--- a/docs/wrangler.jsonc
+++ b/docs/wrangler.jsonc
@@ -1,12 +1,24 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "atom-docs",
+ "main": "./worker/index.ts",
"compatibility_date": "2026-06-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"observability": {
"enabled": true
},
"assets": {
- "directory": "./out"
- }
+ "directory": "./out",
+ "binding": "ASSETS",
+ // Default (false): serve a matching static file directly and only
+ // invoke worker/index.ts for requests that don't match one -- e.g.
+ // "/docs/atom/img/...", which the static export no longer contains.
+ "run_worker_first": false
+ },
+ "r2_buckets": [
+ {
+ "binding": "IMAGES_BUCKET",
+ "bucket_name": "websites-images"
+ }
+ ]
}