diff --git a/.gitignore b/.gitignore
index b6d0ed3..157fc9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!scripts/.env.publish-image.example
# vercel
.vercel
diff --git a/README.md b/README.md
index 558440a..f8eeff6 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,9 @@ Documentation site for [Cube AI](https://github.com/ultravioletrs/cube), served
Built with [Next.js](https://nextjs.org) and [Fumadocs](https://fumadocs.dev), deployed to Cloudflare Workers as a static export nested under `/docs/cube-ai/`.
+Images are served from a shared Cloudflare R2 bucket rather than committed to the repo —
+see [`scripts/README.md`](./scripts/README.md).
+
## Development
```bash
@@ -49,18 +52,22 @@ lib/
metadata.ts # createMetadata helper
source.ts # Fumadocs source loader
public/
- img/ # Images referenced by docs
_headers # Cloudflare cache headers
_redirects # Cloudflare redirects
robots.txt
scripts/
nest-static-export.mjs # Post-build: nest out/ under docs/cube-ai/
-wrangler.jsonc # Cloudflare Workers config
+ publish-image.mjs # Maintainer-only: upload an image to R2 + purge cache
+worker/
+ index.ts # Cloudflare Worker (main): serves /img/... from R2, else static assets
+ r2-proxy.ts # Shared R2 lookup + streaming logic
+wrangler.jsonc # Cloudflare Workers config (assets + Worker + R2 binding)
biome.json # Linter / formatter (Biome)
```
## Tooling
- **Linter / Formatter**: [Biome](https://biomejs.dev)
-- **Deployment**: Cloudflare Workers (static assets via `wrangler deploy`)
+- **Deployment**: Cloudflare Workers (static assets + a small Worker for the R2-backed
+ image route, via `wrangler deploy`)
- **CI**: GitHub Actions — lint → type check → build
diff --git a/mdx-components.tsx b/mdx-components.tsx
index d56548b..c282aab 100644
--- a/mdx-components.tsx
+++ b/mdx-components.tsx
@@ -1,22 +1,41 @@
+import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
-import type { ComponentPropsWithoutRef, ReactNode } from "react";
+import type { ComponentPropsWithoutRef } from "react";
import { Mermaid } from "@/components/mdx/mermaid";
import { assetPath } from "@/lib/base-path";
+import { cn } from "@/lib/utils";
-const DefaultImage = defaultMdxComponents.img as
- | ((props: ComponentPropsWithoutRef<"img">) => ReactNode)
- | undefined;
-
+// Doc content images (content/docs/**/*.mdx) are served from the shared
+// Cloudflare R2 bucket, not committed to this repo (see scripts/README.md),
+// so fumadocs-mdx's static-import-based width/height inference is disabled
+// (source.config.ts: remarkImageOptions: false) - it requires the file on
+// disk at build time, which doesn't hold here. Rendered as a plain,
+// zoomable -- no next/image, no width/height needed, so there's
+// nothing to keep in sync when images change.
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
- img: (props) => {
- const src =
- typeof props.src === "string" ? assetPath(props.src) : props.src;
+ img: (props: ComponentPropsWithoutRef<"img">) => {
+ const { src: rawSrc, alt, className } = props;
+ if (typeof rawSrc !== "string") return null;
+
+ const src = assetPath(rawSrc);
- if (DefaultImage) return ;
- return null;
+ 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`.
+
+ {/* biome-ignore lint/performance/noImgElement: doc content images are served from R2, not Next's image pipeline */}
+
+
+ );
},
Mermaid,
...components,
diff --git a/package.json b/package.json
index 12fd745..432d454 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
"lint": "biome check",
"format": "biome format --write",
"lint:fix": "biome check --write",
- "lint:md": "markdownlint --disable MD013 MD033 MD007 MD029 MD059 MD024 MD025 -- content/docs"
+ "lint:md": "markdownlint --disable MD013 MD033 MD007 MD029 MD059 MD024 MD025 -- content/docs",
+ "publish-image": "node scripts/publish-image.mjs"
},
"dependencies": {
"@orama/orama": "^3.1.18",
diff --git a/public/img/attestation-policy-ui.png b/public/img/attestation-policy-ui.png
deleted file mode 100644
index 4bd0c08..0000000
Binary files a/public/img/attestation-policy-ui.png and /dev/null differ
diff --git a/public/img/attestation-report-ui.png b/public/img/attestation-report-ui.png
deleted file mode 100644
index c9e4b3d..0000000
Binary files a/public/img/attestation-report-ui.png and /dev/null differ
diff --git a/public/img/buildroot-cube-agent-config.png b/public/img/buildroot-cube-agent-config.png
deleted file mode 100644
index dc08059..0000000
Binary files a/public/img/buildroot-cube-agent-config.png and /dev/null differ
diff --git a/public/img/buildroot-cube-ai-services-menu.png b/public/img/buildroot-cube-ai-services-menu.png
deleted file mode 100644
index 05d4a0a..0000000
Binary files a/public/img/buildroot-cube-ai-services-menu.png and /dev/null differ
diff --git a/public/img/buildroot-llm-backend-selection.png b/public/img/buildroot-llm-backend-selection.png
deleted file mode 100644
index 9d441c1..0000000
Binary files a/public/img/buildroot-llm-backend-selection.png and /dev/null differ
diff --git a/public/img/buildroot-llm-custom-url.png b/public/img/buildroot-llm-custom-url.png
deleted file mode 100644
index c8856ce..0000000
Binary files a/public/img/buildroot-llm-custom-url.png and /dev/null differ
diff --git a/public/img/buildroot-ollama-custom-models.png b/public/img/buildroot-ollama-custom-models.png
deleted file mode 100644
index 3a8b6a6..0000000
Binary files a/public/img/buildroot-ollama-custom-models.png and /dev/null differ
diff --git a/public/img/chat-sidebar.png b/public/img/chat-sidebar.png
deleted file mode 100644
index d7e0603..0000000
Binary files a/public/img/chat-sidebar.png and /dev/null differ
diff --git a/public/img/chat-ui.png b/public/img/chat-ui.png
deleted file mode 100644
index 423fa14..0000000
Binary files a/public/img/chat-ui.png and /dev/null differ
diff --git a/public/img/continue-chat.png b/public/img/continue-chat.png
deleted file mode 100644
index cbeef55..0000000
Binary files a/public/img/continue-chat.png and /dev/null differ
diff --git a/public/img/continue-config.png b/public/img/continue-config.png
deleted file mode 100644
index 859bcde..0000000
Binary files a/public/img/continue-config.png and /dev/null differ
diff --git a/public/img/continue-marketplace.png b/public/img/continue-marketplace.png
deleted file mode 100644
index 409cdbc..0000000
Binary files a/public/img/continue-marketplace.png and /dev/null differ
diff --git a/public/img/cube-ai-architecture.png b/public/img/cube-ai-architecture.png
deleted file mode 100644
index cadf61a..0000000
Binary files a/public/img/cube-ai-architecture.png and /dev/null differ
diff --git a/public/img/cube-ai-auth-flow.png b/public/img/cube-ai-auth-flow.png
deleted file mode 100644
index c6d73bf..0000000
Binary files a/public/img/cube-ai-auth-flow.png and /dev/null differ
diff --git a/public/img/cube-vllm-flow.png b/public/img/cube-vllm-flow.png
deleted file mode 100644
index 02e78de..0000000
Binary files a/public/img/cube-vllm-flow.png and /dev/null differ
diff --git a/public/img/getting-started-chat.png b/public/img/getting-started-chat.png
deleted file mode 100644
index d9ed83d..0000000
Binary files a/public/img/getting-started-chat.png and /dev/null differ
diff --git a/public/img/getting-started-create-domain.png b/public/img/getting-started-create-domain.png
deleted file mode 100644
index de6e212..0000000
Binary files a/public/img/getting-started-create-domain.png and /dev/null differ
diff --git a/public/img/getting-started-login.png b/public/img/getting-started-login.png
deleted file mode 100644
index a4d20ad..0000000
Binary files a/public/img/getting-started-login.png and /dev/null differ
diff --git a/public/img/logos/altLogo.svg b/public/img/logos/altLogo.svg
deleted file mode 100644
index 0ac2c09..0000000
--- a/public/img/logos/altLogo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/img/logos/sidebarLogo.svg b/public/img/logos/sidebarLogo.svg
deleted file mode 100644
index 503bcec..0000000
--- a/public/img/logos/sidebarLogo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/img/model-selector.png b/public/img/model-selector.png
deleted file mode 100644
index 42d40a4..0000000
Binary files a/public/img/model-selector.png and /dev/null differ
diff --git a/public/img/models-registry.png b/public/img/models-registry.png
deleted file mode 100644
index 06ffb95..0000000
Binary files a/public/img/models-registry.png and /dev/null differ
diff --git a/public/img/translations-flow.png b/public/img/translations-flow.png
deleted file mode 100644
index f6231a9..0000000
Binary files a/public/img/translations-flow.png and /dev/null differ
diff --git a/public/img/ui/accept-invite.png b/public/img/ui/accept-invite.png
deleted file mode 100644
index 111ff9f..0000000
Binary files a/public/img/ui/accept-invite.png and /dev/null differ
diff --git a/public/img/ui/assign-user.png b/public/img/ui/assign-user.png
deleted file mode 100644
index e365a97..0000000
Binary files a/public/img/ui/assign-user.png and /dev/null differ
diff --git a/public/img/ui/audit-logs.png b/public/img/ui/audit-logs.png
deleted file mode 100644
index ba27420..0000000
Binary files a/public/img/ui/audit-logs.png and /dev/null differ
diff --git a/public/img/ui/audit-tee-attestation-details.png b/public/img/ui/audit-tee-attestation-details.png
deleted file mode 100644
index ca2631b..0000000
Binary files a/public/img/ui/audit-tee-attestation-details.png and /dev/null differ
diff --git a/public/img/ui/audit-tee-attestation.png b/public/img/ui/audit-tee-attestation.png
deleted file mode 100644
index 00fc684..0000000
Binary files a/public/img/ui/audit-tee-attestation.png and /dev/null differ
diff --git a/public/img/ui/change-password.png b/public/img/ui/change-password.png
deleted file mode 100644
index b26aa13..0000000
Binary files a/public/img/ui/change-password.png and /dev/null differ
diff --git a/public/img/ui/chat.png b/public/img/ui/chat.png
deleted file mode 100644
index ef0513a..0000000
Binary files a/public/img/ui/chat.png and /dev/null differ
diff --git a/public/img/ui/create-role.png b/public/img/ui/create-role.png
deleted file mode 100644
index 538bf94..0000000
Binary files a/public/img/ui/create-role.png and /dev/null differ
diff --git a/public/img/ui/domain-members-roles.png b/public/img/ui/domain-members-roles.png
deleted file mode 100644
index 2b5656f..0000000
Binary files a/public/img/ui/domain-members-roles.png and /dev/null differ
diff --git a/public/img/ui/domains.png b/public/img/ui/domains.png
deleted file mode 100644
index 784a607..0000000
Binary files a/public/img/ui/domains.png and /dev/null differ
diff --git a/public/img/ui/forgot-password.png b/public/img/ui/forgot-password.png
deleted file mode 100644
index 86bd607..0000000
Binary files a/public/img/ui/forgot-password.png and /dev/null differ
diff --git a/public/img/ui/guardrails-audit-logs-requests.png b/public/img/ui/guardrails-audit-logs-requests.png
deleted file mode 100644
index 2204303..0000000
Binary files a/public/img/ui/guardrails-audit-logs-requests.png and /dev/null differ
diff --git a/public/img/ui/guardrails-chat-allowed.png b/public/img/ui/guardrails-chat-allowed.png
deleted file mode 100644
index 41a9fe5..0000000
Binary files a/public/img/ui/guardrails-chat-allowed.png and /dev/null differ
diff --git a/public/img/ui/guardrails-chat-blocked.png b/public/img/ui/guardrails-chat-blocked.png
deleted file mode 100644
index 73fd5e1..0000000
Binary files a/public/img/ui/guardrails-chat-blocked.png and /dev/null differ
diff --git a/public/img/ui/guardrails-chat-restricted.png b/public/img/ui/guardrails-chat-restricted.png
deleted file mode 100644
index 5c08285..0000000
Binary files a/public/img/ui/guardrails-chat-restricted.png and /dev/null differ
diff --git a/public/img/ui/guardrails-config.png b/public/img/ui/guardrails-config.png
deleted file mode 100644
index a25deec..0000000
Binary files a/public/img/ui/guardrails-config.png and /dev/null differ
diff --git a/public/img/ui/guardrails-create.png b/public/img/ui/guardrails-create.png
deleted file mode 100644
index 21b5f2d..0000000
Binary files a/public/img/ui/guardrails-create.png and /dev/null differ
diff --git a/public/img/ui/guardrails-list.png b/public/img/ui/guardrails-list.png
deleted file mode 100644
index ee17770..0000000
Binary files a/public/img/ui/guardrails-list.png and /dev/null differ
diff --git a/public/img/ui/guardrails-version.png b/public/img/ui/guardrails-version.png
deleted file mode 100644
index f8a5632..0000000
Binary files a/public/img/ui/guardrails-version.png and /dev/null differ
diff --git a/public/img/ui/guardrails.png b/public/img/ui/guardrails.png
deleted file mode 100644
index 28a511e..0000000
Binary files a/public/img/ui/guardrails.png and /dev/null differ
diff --git a/public/img/ui/invitations.png b/public/img/ui/invitations.png
deleted file mode 100644
index 59efe17..0000000
Binary files a/public/img/ui/invitations.png and /dev/null differ
diff --git a/public/img/ui/invite-member.png b/public/img/ui/invite-member.png
deleted file mode 100644
index 420430c..0000000
Binary files a/public/img/ui/invite-member.png and /dev/null differ
diff --git a/public/img/ui/login.png b/public/img/ui/login.png
deleted file mode 100644
index 65640d1..0000000
Binary files a/public/img/ui/login.png and /dev/null differ
diff --git a/public/img/ui/members.png b/public/img/ui/members.png
deleted file mode 100644
index 36632b5..0000000
Binary files a/public/img/ui/members.png and /dev/null differ
diff --git a/public/img/ui/models.png b/public/img/ui/models.png
deleted file mode 100644
index 3306d22..0000000
Binary files a/public/img/ui/models.png and /dev/null differ
diff --git a/public/img/ui/pats-add-scope.png b/public/img/ui/pats-add-scope.png
deleted file mode 100644
index bfe8e18..0000000
Binary files a/public/img/ui/pats-add-scope.png and /dev/null differ
diff --git a/public/img/ui/pats-create-dialog.png b/public/img/ui/pats-create-dialog.png
deleted file mode 100644
index 25e1f00..0000000
Binary files a/public/img/ui/pats-create-dialog.png and /dev/null differ
diff --git a/public/img/ui/pats-list.png b/public/img/ui/pats-list.png
deleted file mode 100644
index bba419e..0000000
Binary files a/public/img/ui/pats-list.png and /dev/null differ
diff --git a/public/img/ui/pats-revoke-confirmation.png b/public/img/ui/pats-revoke-confirmation.png
deleted file mode 100644
index 247f9fd..0000000
Binary files a/public/img/ui/pats-revoke-confirmation.png and /dev/null differ
diff --git a/public/img/ui/profile-page.png b/public/img/ui/profile-page.png
deleted file mode 100644
index 0978ab0..0000000
Binary files a/public/img/ui/profile-page.png and /dev/null differ
diff --git a/public/img/ui/profile-pats-menu.png b/public/img/ui/profile-pats-menu.png
deleted file mode 100644
index 0e57ec8..0000000
Binary files a/public/img/ui/profile-pats-menu.png and /dev/null differ
diff --git a/public/img/ui/role-actions.png b/public/img/ui/role-actions.png
deleted file mode 100644
index 94b3a6b..0000000
Binary files a/public/img/ui/role-actions.png and /dev/null differ
diff --git a/public/img/ui/role-details.png b/public/img/ui/role-details.png
deleted file mode 100644
index 838d913..0000000
Binary files a/public/img/ui/role-details.png and /dev/null differ
diff --git a/public/img/ui/roles.png b/public/img/ui/roles.png
deleted file mode 100644
index 4138ac8..0000000
Binary files a/public/img/ui/roles.png and /dev/null differ
diff --git a/public/img/ui/signup.png b/public/img/ui/signup.png
deleted file mode 100644
index f575e6b..0000000
Binary files a/public/img/ui/signup.png and /dev/null differ
diff --git a/public/img/ui/user-menu-logout.png b/public/img/ui/user-menu-logout.png
deleted file mode 100644
index ea167bb..0000000
Binary files a/public/img/ui/user-menu-logout.png and /dev/null differ
diff --git a/public/img/vllm-architecture.png b/public/img/vllm-architecture.png
deleted file mode 100644
index 224e79d..0000000
Binary files a/public/img/vllm-architecture.png and /dev/null differ
diff --git a/scripts/.env.publish-image.example b/scripts/.env.publish-image.example
new file mode 100644
index 0000000..7397371
--- /dev/null
+++ b/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 ultraviolet.rs zone)
+CLOUDFLARE_API_TOKEN=
+
+# Not secret - the ultraviolet.rs zone ID. Confirmed via a live Cloudflare
+# API lookup while migrating the sibling ultraviolet/website repo; reused
+# here since this site is served under the same domain/zone.
+CLOUDFLARE_ZONE_ID=cbb18a791e53fb24efbeb169e48634df
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..cbbe387
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,157 @@
+# Publishing images (maintainers only)
+
+Images are no longer committed to this repo. They're stored in a shared Cloudflare R2
+bucket (`websites-images`, shared across several properties, this site's objects live
+under the `cube-docs` key prefix) and served at their usual `/img/...` URLs by a small
+Cloudflare Worker that sits in front of the static export.
+
+This site builds with `output: "export"` (see [`next.config.mjs`](../next.config.mjs)) —
+it has no Next.js server runtime on Cloudflare, so there's no API route to hang a proxy
+off. Instead, [`wrangler.jsonc`](../wrangler.jsonc) wires up a plain Worker
+([`worker/index.ts`](../worker/index.ts)) as `main` alongside the static assets binding.
+Cloudflare only invokes that Worker when a request doesn't match a file in the static
+export, which is exactly the case for images now that they live in R2 instead of
+`public/img/`. The Worker checks whether the request falls under the site's `/img/...`
+route and, if so, streams the object back from R2 via
+[`worker/r2-proxy.ts`](../worker/r2-proxy.ts); everything else falls through to
+`env.ASSETS.fetch(request)` unchanged. Nothing in `content/docs/*.mdx` or components
+changes — they keep referencing `/img/ui/domains.png` etc. exactly as before, and
+[`mdx-components.tsx`](../mdx-components.tsx) / [`lib/base-path.ts`](../lib/base-path.ts)
+still prepend the `/docs/cube-ai` basePath the same way they always did.
+
+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 for the
+reasoning.
+
+## 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 `ultraviolet.rs` zone
+
+3. Paste the token into `CLOUDFLARE_API_TOKEN` in `scripts/.env.publish-image`. Fill in
+ `CLOUDFLARE_ZONE_ID` too — it's the `ultraviolet.rs` zone ID from that domain's
+ Overview page in the Cloudflare dashboard. It's not secret (can't authenticate
+ anything by itself), but isn't filled in here since it wasn't confirmed while writing
+ this migration — fill it in once and it can be committed to the example file for the
+ next person if you want.
+
+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 (`.env*` pattern in `.gitignore`). Never commit
+it, never paste the token value into a PR, issue, or chat.
+
+## Publishing an image
+
+```bash
+pnpm run publish-image
+```
+
+`` is the site-relative path as written in MDX/components — it must start
+with `img/` so the script knows it belongs to the image route. Example:
+
+```bash
+pnpm run publish-image ./domains.png img/ui/domains.png
+# -> https://www.ultraviolet.rs/docs/cube-ai/img/ui/domains.png
+```
+
+Keep the public path identical to the existing `/img/...` convention (check
+`content/docs/**/*.mdx` for `` references, or `public/img/` before it's
+removed) so MDX/component references don't need to change.
+
+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.
+
+## Bulk-uploading via the R2 dashboard (initial migration)
+
+For migrating the existing `public/img/` tree in bulk instead of one file at a time, you
+can drag-and-drop folders into the bucket in the Cloudflare dashboard. **The keys have to
+land under the exact prefix the Worker expects:**
+
+| Site URL | Required R2 key |
+| ---------------------------------------------------------------- | ------------------------------- |
+| `https://www.ultraviolet.rs/docs/cube-ai/img/ui/domains.png` | `cube-docs/ui/domains.png` |
+| `https://www.ultraviolet.rs/docs/cube-ai/img/logos/altLogo.svg` | `cube-docs/logos/altLogo.svg` |
+
+So in the R2 dashboard, in the `websites-images` bucket, create/open a folder named
+`cube-docs` and drag in the **contents** of `public/img/` (the `ui/`, `logos/`, and any
+top-level files) — not the `img` folder itself as one more nested level. Dragging `img/`
+in as a folder would produce `cube-docs/img/ui/domains.png`, which the Worker never looks
+up (it strips the leading `/docs/cube-ai/img/` from the request and prepends `cube-docs/`,
+nothing else) — every image would silently 404.
+
+Every file under `public/img/` at the time of this migration was confirmed referenced from
+`content/docs/**/*.mdx` or a component (`BrandLogo.tsx`). If you add new images later,
+upload them the same way before merging so the tree doesn't silently reaccumulate orphaned
+files.
+
+**Before merging the branch that removes `public/img/`**, all 60 files it currently
+contains need to be uploaded to R2 under `cube-docs/` first (one-time bulk step above, or
+looping `publish-image` over each file) — otherwise every image on the site 404s the
+moment it deploys.
+
+## Why maintainer-only
+
+This repo is public. The risk isn't the script being visible — it's inert without a
+credential. The risk is _credential distribution_: whoever holds `CLOUDFLARE_API_TOKEN`
+can write to the shared bucket. So nobody, internal or external, gets a personal R2 token.
+Only a maintainer, holding this one scoped token, runs `publish-image`.
+
+Practical flow for a PR that adds an image (contributor is internal or external, doesn't
+matter): the contributor attaches the image to the PR the normal GitHub way (drag-and-drop
+into the description or a comment). A maintainer reviewing the PR runs
+`pnpm run publish-image` locally before merging, then approves. If this becomes a frequent
+bottleneck, the natural next step is a label- or comment-triggered GitHub Action that runs
+the same script with the token stored as a repo secret — but that automation must only ever
+read the attachment URL/destination path from the PR, never execute code from the PR
+branch while the token is in scope (the standard `pull_request_target` secret-exfiltration
+pitfall).
+
+## 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 must be the site-relative
+ path including the `img/` route, e.g. `img/ui/domains.png`, not `ui/domains.png`.
+- **`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 `ultraviolet.rs` 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/cube-docs/ --remote --file=/tmp/check
+ ```
diff --git a/scripts/publish-image.mjs b/scripts/publish-image.mjs
new file mode 100644
index 0000000..96f7780
--- /dev/null
+++ b/scripts/publish-image.mjs
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+// Maintainer-only. Uploads an 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 ultraviolet.rs) and CLOUDFLARE_ZONE_ID.
+//
+// Usage:
+// pnpm run publish-image
+//
+// is the site-relative path as written in MDX/components —
+// starting with "img/" to match the route the Worker serves it back on:
+// pnpm run publish-image ./domains.png img/ui/domains.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";
+const SITE_ORIGIN = "https://www.ultraviolet.rs";
+// Matches next.config.mjs's basePath — the site is nested under this path
+// on ultraviolet.rs, so the full public URL is SITE_ORIGIN + BASE_PATH + "/" + destKey.
+const BASE_PATH = "docs/cube-ai";
+
+// Maps the leading path segment (the site route) to where objects for it
+// live in the bucket. Keep in sync with worker/index.ts.
+const ROUTES = {
+ img: { keyPrefix: "cube-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 ./domains.png img/ui/domains.png",
+ );
+ process.exit(1);
+}
+
+if (!existsSync(localFile)) {
+ console.error(`Local file not found: ${localFile}`);
+ process.exit(1);
+}
+
+const destKey = publicPath.replace(/^\/+/, "");
+const [routeName, ...restParts] = destKey.split("/");
+const route = ROUTES[routeName];
+if (!route || restParts.length === 0) {
+ console.error(
+ `Destination must start with "img/" and include a path, got: ${destKey}`,
+ );
+ process.exit(1);
+}
+const restPath = restParts.join("/");
+
+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}/${route.keyPrefix}/${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/source.config.ts b/source.config.ts
index f7bd42c..c542e59 100644
--- a/source.config.ts
+++ b/source.config.ts
@@ -25,6 +25,14 @@ export default defineConfig({
},
langs: ["lua", "typescript", "bash", "javascript", "go"],
},
- // MDX options
+ // Images are served from R2 (see scripts/README.md), not committed to
+ // this repo, so fumadocs-mdx's default behavior of resolving local
+ // `/img/...` references against the public/ directory at build time
+ // (to statically import them and infer width/height) doesn't apply
+ // here - it would fail the build once those files are removed. With
+ // this off, `` compiles to a plain ,
+ // which mdx-components.tsx's img override already rewrites through
+ // assetPath() the same way it does for component-referenced images.
+ remarkImageOptions: false,
},
});
diff --git a/worker/index.ts b/worker/index.ts
new file mode 100644
index 0000000..181f07c
--- /dev/null
+++ b/worker/index.ts
@@ -0,0 +1,42 @@
+import { BASE_PATH } from "../lib/base-path";
+import { type ExecutionContext, type R2Bucket, serveFromR2 } from "./r2-proxy";
+
+// This site has no server-side Next.js runtime on Cloudflare (it's a plain
+// `output: "export"` build, not @cloudflare/next-on-pages or
+// @opennextjs/cloudflare) — see scripts/README.md. This Worker is the
+// `main` entry wired up alongside the static assets in wrangler.jsonc, and
+// Cloudflare only invokes it when a request doesn't match a static file.
+// Images used to be committed under public/img and matched directly; now
+// that they live in R2 instead, requests for them fall through here.
+
+// Every property served from the shared "websites-images" R2 bucket gets
+// its own key prefix so objects don't collide; this site's is "cube-docs".
+// Keep in sync with scripts/publish-image.mjs.
+const KEY_PREFIX = "cube-docs";
+const IMG_ROUTE_PREFIX = `${BASE_PATH}/img/`;
+
+interface Fetcher {
+ fetch(request: Request): Promise;
+}
+
+interface Env {
+ ASSETS: Fetcher;
+ IMAGES_BUCKET: R2Bucket;
+}
+
+export default {
+ async fetch(
+ request: Request,
+ env: Env,
+ ctx: ExecutionContext,
+ ): Promise {
+ const url = new URL(request.url);
+
+ if (url.pathname.startsWith(IMG_ROUTE_PREFIX)) {
+ const restPath = url.pathname.slice(IMG_ROUTE_PREFIX.length);
+ return serveFromR2(request, env.IMAGES_BUCKET, KEY_PREFIX, restPath, ctx);
+ }
+
+ return env.ASSETS.fetch(request);
+ },
+};
diff --git a/worker/r2-proxy.ts b/worker/r2-proxy.ts
new file mode 100644
index 0000000..0b860e1
--- /dev/null
+++ b/worker/r2-proxy.ts
@@ -0,0 +1,72 @@
+interface R2ObjectBody {
+ body: ReadableStream;
+ size: number;
+ httpEtag: string;
+ writeHttpMetadata(headers: Headers): void;
+}
+
+export interface R2Bucket {
+ get(key: string): Promise;
+}
+
+export interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+}
+
+interface CFCache {
+ match(request: Request): Promise;
+ put(request: Request, response: Response): Promise;
+}
+interface CFCacheStorage {
+ readonly default: CFCache;
+}
+
+const notFound = () =>
+ new Response("Not found", {
+ status: 404,
+ headers: { "cache-control": "no-store" },
+ });
+
+// Shared bucket ("websites-images") holds assets for multiple properties;
+// keyPrefix keeps this site's objects from colliding with the others.
+export async function serveFromR2(
+ request: Request,
+ bucket: R2Bucket,
+ keyPrefix: string,
+ restPath: string,
+ ctx: ExecutionContext,
+): Promise {
+ if (!restPath) return notFound();
+
+ // 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 the publish-image script.
+ 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 bucket.get(`${keyPrefix}/${restPath}`);
+ 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 -- the publish-image script 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/wrangler.jsonc b/wrangler.jsonc
index 4afce5e..80411d2 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -1,12 +1,20 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cube-docs",
+ "main": "worker/index.ts",
"compatibility_date": "2026-05-26",
"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"
+ }
+ ]
}