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 b6b6b31..9d04fc4 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,8 @@ This site uses:
- **Next.js static export** — `next build` outputs static files to `out/`
- **Next.js `basePath`** — generates links and assets under `/docs/propeller`
-- **Post-build nesting** — `scripts/nest-static-export.mjs` moves the export under `out/docs/propeller/` 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/propeller/` so Cloudflare static assets can serve it from the route prefix
+- **Worker-proxied doc images** — `worker/index.ts` (the `main` entry in `wrangler.jsonc`) sits in front of the static assets and serves `/docs/propeller/img/*` requests directly from the shared `websites-images` R2 bucket; every other request falls through to the static asset binding unchanged. See [`scripts/README.md`](./scripts/README.md) for how images get published there.
### Cloudflare build settings (Dashboard)
@@ -45,9 +46,11 @@ flowchart LR
end
subgraph Runtime_Request_Flow
- U[Browser request] --> H[Cloudflare static asset route]
- H --> J[Static asset lookup]
- J --> U
+ U[Browser request] --> W[worker/index.ts]
+ W -->|"/docs/propeller/img/*"| R[(R2: websites-images)]
+ W -->|everything else| H[ASSETS binding]
+ R --> U
+ H --> U
end
```
@@ -74,6 +77,9 @@ Set this as a Cloudflare build variable so it is embedded into the static output
| `src/lib/layout.shared.tsx` | Shared layout options |
| `content/openapi.yaml` | OpenAPI spec (generates API docs) |
| `scripts/nest-static-export.mjs` | Moves static export under `/docs/propeller` |
+| `worker/index.ts` | Cloudflare Worker: proxies `/docs/propeller/img/*` from R2, falls through to static assets otherwise |
+| `src/lib/remark-doc-images.ts` | Remark plugin resolving markdown image paths (relative to their source file) to their R2-proxy URL at build time |
+| `scripts/publish-image.mjs` | Maintainer-only: uploads a content image to R2 and purges its cache entry |
## Learn More
diff --git a/content/docs/images/architecture.svg b/content/docs/images/architecture.svg
deleted file mode 100644
index 0458382..0000000
--- a/content/docs/images/architecture.svg
+++ /dev/null
@@ -1,968 +0,0 @@
-
-
-
-
diff --git a/content/docs/images/dag/architecture.svg b/content/docs/images/dag/architecture.svg
deleted file mode 100644
index c911e80..0000000
--- a/content/docs/images/dag/architecture.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Manager
Orchestration
Jobs
Workflows
Standalone Tasks
Scheduling
Priority
Cron
Round Robin
Execution
Tasks
Proplets
Proplet A
Proplet B
Proplet C
\ No newline at end of file
diff --git a/content/docs/images/dag/basic-dag.svg b/content/docs/images/dag/basic-dag.svg
deleted file mode 100644
index 9a5032c..0000000
--- a/content/docs/images/dag/basic-dag.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
A
B
C
D
\ No newline at end of file
diff --git a/content/docs/images/dag/conditional-branching.svg b/content/docs/images/dag/conditional-branching.svg
deleted file mode 100644
index 6b41832..0000000
--- a/content/docs/images/dag/conditional-branching.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Parent Task
Child Task (run_if: success)
Child Task (run_if: failure)
Skipped (Terminal State)
All parents succeeded
At least one parent failed
Condition not met → Marked Skipped
Condition not met → Marked Skipped
\ No newline at end of file
diff --git a/content/docs/images/dag/data-passing-between-tasks.svg b/content/docs/images/dag/data-passing-between-tasks.svg
deleted file mode 100644
index 299dbd1..0000000
--- a/content/docs/images/dag/data-passing-between-tasks.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Task A Produces Results
Task B Receives A's Results Produces Results
Task C Receives Results from A & B
results
results
results
\ No newline at end of file
diff --git a/content/docs/images/dag/dependency.svg b/content/docs/images/dag/dependency.svg
deleted file mode 100644
index ef204c1..0000000
--- a/content/docs/images/dag/dependency.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Task A
Task B
Task C
\ No newline at end of file
diff --git a/content/docs/images/dag/example-dag.svg b/content/docs/images/dag/example-dag.svg
deleted file mode 100644
index b0d6d97..0000000
--- a/content/docs/images/dag/example-dag.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Task 1
Task 2
Task 3
\ No newline at end of file
diff --git a/content/docs/images/dag/fanout-fanin.svg b/content/docs/images/dag/fanout-fanin.svg
deleted file mode 100644
index a82b481..0000000
--- a/content/docs/images/dag/fanout-fanin.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-IngestResizeWatermarkCompressMerge & Upload
\ No newline at end of file
diff --git a/content/docs/images/dag/priority-within-level.svg b/content/docs/images/dag/priority-within-level.svg
deleted file mode 100644
index 60162a8..0000000
--- a/content/docs/images/dag/priority-within-level.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Scheduler (Multiple Tasks Ready)
Task A Priority: 90 Created: 10:00
Task B Priority: 70 Created: 09:55
Task C Priority: 70 Created: 10:05
1️⃣ Starts First (Highest Priority)
2️⃣ Starts Next (Equal Priority, Older First)
3️⃣ Starts After (Equal Priority, Newer)
Ready
Ready
Ready
\ No newline at end of file
diff --git a/content/docs/images/dag/run-if-success-failure-branching.svg b/content/docs/images/dag/run-if-success-failure-branching.svg
deleted file mode 100644
index 9a989df..0000000
--- a/content/docs/images/dag/run-if-success-failure-branching.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Payment Task
Send Confirmation (run_if: success)
Send Failure Alert (run_if: failure)
on success
on failure
\ No newline at end of file
diff --git a/content/docs/images/dag/state-transitions-in-workflows.svg b/content/docs/images/dag/state-transitions-in-workflows.svg
deleted file mode 100644
index 7ecb66d..0000000
--- a/content/docs/images/dag/state-transitions-in-workflows.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
Pending (0)
Scheduled (1)
Running (2)
Completed (3)
Failed (4)
Skipped (5)
Interrupted (6)
assign to proplet
proplet starts
success
error
run_if = false
stop
restart
\ No newline at end of file
diff --git a/content/docs/images/fml/federated-ml-architecture.svg b/content/docs/images/fml/federated-ml-architecture.svg
deleted file mode 100644
index a0e55b6..0000000
--- a/content/docs/images/fml/federated-ml-architecture.svg
+++ /dev/null
@@ -1,517 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/content/docs/images/tee/attestation.svg b/content/docs/images/tee/attestation.svg
deleted file mode 100644
index b4ff987..0000000
--- a/content/docs/images/tee/attestation.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
TEE Attestation Flow
Proplet
• Detects TEE environment • Receives encrypted task request • Needs decryption key from KBS
Attestation Agent (AA)
• Interacts with TEE hardware APIs • Generates nonce for freshness
TEE Hardware
• Generates attestation report • Report includes: - TEE type (TDX/SEV-SNP/SGX) - TEE configuration - Runtime measurement - Cryptographic signature
Attestation Agent (AA)
• Formats attestation report • Sends to KBS for validation
Key Broker Service (KBS)
Validation Steps: 1. Verify hardware signature 2. Check TEE type is supported 3. Verify TEE configuration matches policy 4. Confirm runtime measurement is expected
If validation succeeds: • Retrieve private key from secure storage • Return decryption key to Proplet
If validation fails: Deny key request • Log security event
Proplet
• Receives decryption key • Decrypts WASM image inside TEE • Executes workload in protected environment
Request attestation via AA
Get TEE evidence
Attestation report
Validate & request key
Decryption key (if valid)
\ No newline at end of file
diff --git a/content/docs/images/tee/encrypted-task-execution.svg b/content/docs/images/tee/encrypted-task-execution.svg
deleted file mode 100644
index df2eade..0000000
--- a/content/docs/images/tee/encrypted-task-execution.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
CLI/User
Manager
POST /tasks
Proplet (in TEE)
3. Detect TEE Environment
4. Pull Encrypted Image
5. Request Attestation
6. Attestation Agent
publish start
7. Key Broker Service (KBS)
attestation
Proplet (in TEE)
8. Decrypt Image Inside TEE
9. Execute WASM Workload
10. Return Results
decryption key
\ No newline at end of file
diff --git a/content/docs/images/tee/image-encryption.svg b/content/docs/images/tee/image-encryption.svg
deleted file mode 100644
index 8e616e9..0000000
--- a/content/docs/images/tee/image-encryption.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/package.json b/package.json
index abb4280..a40af44 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"
},
"dependencies": {
diff --git a/scripts/.env.publish-image.example b/scripts/.env.publish-image.example
new file mode 100644
index 0000000..554a8c4
--- /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/propeller) is
+# served under that domain (see README.md's NEXT_PUBLIC_BASE_URL); 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..2079142
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,147 @@
+# 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 `propeller-docs` key prefix so they
+don't collide with other properties in the same bucket) and served at
+`/docs/propeller/img/` by a small Cloudflare Worker script,
+[`worker/index.ts`](../worker/index.ts), that reads the object from R2 and streams it
+back.
+
+**Authoring is unchanged** — write plain markdown image syntax exactly as before, with
+whatever relative path you'd naturally use from the `.mdx` file you're editing:
+
+```md
+
+```
+
+[`src/lib/remark-doc-images.ts`](../src/lib/remark-doc-images.ts) resolves that path
+(relative to the source file's own location — pure path math, no image bytes needed) into
+the literal `/docs/propeller/img/dag/architecture.svg` URL the Worker serves, at compile
+time. `src/mdx-components.tsx`'s `img:` override then renders it as a plain, zoomable
+`` (`fumadocs-ui`'s `ImageZoom` wrapping a plain element, not `next/image`) — no
+width/height needed, so there's no manifest to keep in sync when images change.
+
+## 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. 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, fumadocs-mdx's `remarkImage` plugin resolved markdown image syntax
+into a webpack `import` at build time, content-hashed 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. `source.config.ts` disables that plugin, and
+`wrangler.jsonc` has a `main` Worker script (`worker/index.ts`) purely to answer the
+`/docs/propeller/img/*` 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 the request 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 (`www.absmach.eu` — see `scripts/.env.publish-image.example`)
+
+3. Paste the token into `CLOUDFLARE_API_TOKEN` in `scripts/.env.publish-image`
+ (`CLOUDFLARE_ZONE_ID` is pre-filled — it isn't secret).
+
+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
+```
+
+`` must start with `img/` and include the rest of the path MDX content will
+reference. Example:
+
+```bash
+pnpm run publish-image ./architecture.svg img/dag/architecture.svg
+# -> uploaded to r2://websites-images/propeller-docs/dag/architecture.svg
+# -> live at https://www.absmach.eu/docs/propeller/img/dag/architecture.svg
+# -> reference from MDX with any relative path resolving to
+# content/docs/images/dag/architecture.svg, e.g. 
+```
+
+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 or the
+MDX that references it.
+
+If you're adding a brand-new diagram: drop it wherever makes sense under
+`content/docs/images/` conceptually (the directory itself no longer exists in git, but the
+path still determines the R2 key — see above), run `publish-image` on it, then reference
+it from your MDX with the matching relative path — no separate registration step.
+
+## Migrating the existing diagrams (one-time, already done)
+
+This repo's 40 actually-referenced diagrams (5 more existed under `content/docs/images/`
+but weren't linked from any `.mdx` file, so were dropped rather than migrated) have
+already been uploaded to the real R2 bucket and spot-checked byte-for-byte against the
+originals. Nothing further to do here unless a diagram 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 diagram: 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/propeller-docs/ --remote --file=/tmp/check
+ ```
diff --git a/scripts/publish-image.mjs b/scripts/publish-image.mjs
new file mode 100644
index 0000000..227987c
--- /dev/null
+++ b/scripts/publish-image.mjs
@@ -0,0 +1,134 @@
+#!/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 ./architecture.svg img/dag/architecture.svg
+// -> referenced in MDX as  (or any
+// relative path that resolves to content/docs/images/dag/architecture.svg)
+// -> live at https://www.absmach.eu/docs/propeller/img/dag/architecture.svg
+
+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/propeller --
+// 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/propeller";
+
+// Shared bucket ("websites-images") 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 R2_KEY_PREFIX = "propeller-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 ./architecture.svg img/dag/architecture.svg",
+ );
+ 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 both.",
+ );
+ 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}`);
+console.log(
+ `Reference it from MDX with any relative path resolving to content/docs/images/${restPath}, e.g.: `,
+);
diff --git a/source.config.ts b/source.config.ts
index 6702f1d..0ef577f 100644
--- a/source.config.ts
+++ b/source.config.ts
@@ -5,6 +5,7 @@ import {
metaSchema,
} from "fumadocs-mdx/config";
import { z } from "zod";
+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
@@ -25,6 +26,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.svg`, `../images/dag/x.svg`); remarkDocImages
+ // below resolves that to the R2-proxy URL at compile time instead (pure
+ // path math, no manifest), and 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..315563f
--- /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/propeller/img/..." URL the Worker serves, so
+// nothing about the authoring experience changes.
+//
+// - Relative paths ("./images/x.svg", "../images/dag/x.svg", or bare
+// "images/x.svg") 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/propeller/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 d6dede5..cd8e965 100644
--- a/src/mdx-components.tsx
+++ b/src/mdx-components.tsx
@@ -1,10 +1,8 @@
import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock";
-import {
- ImageZoom,
- type ImageZoomProps,
-} from "fumadocs-ui/components/image-zoom";
+import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
+import type { ComponentPropsWithoutRef } from "react";
import { APIPage } from "@/components/api-page";
import { CodeFromSource } from "@/components/code-from-source";
@@ -18,7 +16,26 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
{props.children}
),
- img: (props) => ,
+ // Content images are R2-backed (src/lib/remark-doc-images.ts resolves
+ // the path at compile time; remarkImageOptions is disabled in
+ // source.config.ts so this receives the literal, resolved `` MDX
+ // produced for every `` in content, unmodified). Rendered as
+ // a plain, zoomable -- no next/image, no dimensions needed, same
+ // ImageZoom-wrapped UX this repo had before any of this migration.
+ 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.
+
+ {/* biome-ignore lint/performance/noImgElement: doc content images are served from R2, not Next's image pipeline -- see src/lib/remark-doc-images.ts */}
+
+
+ );
+ },
...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..ab2de30
--- /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/dag/architecture.svg`). 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/propeller/img/` via NEXT_PUBLIC_BASE_PATH in
+// src/components/doc-image.tsx) 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/propeller/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 = "propeller-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 a710f03..a832991 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -1,12 +1,20 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "propeller-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"
+ }
+ ]
}