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 */} + {alt + + ); }, 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 `![...](/img/...)` 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, `![](/img/...)` 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" + } + ] }