emdash-plugin-r2-variants 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # emdash-plugin-r2-variants
2
+
3
+ ## 0.1.0
4
+
5
+ - First release: `Image` component, Portable Text `image` block, dark-mode
6
+ variants, and a daily cleanup job.
7
+ - `variantsBinding` option: store resized copies in a separate public bucket so
8
+ the media bucket stays private.
9
+ - Security: accept only storage keys EmDash itself would serve, validate focal
10
+ points and colours from content, skip originals over 25 MB, validate options.
11
+ - Uses EmDash's exported helpers and constants instead of copies. Removed the
12
+ `prefix` option.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RJS Tech Solutions LLP
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ # emdash-plugin-r2-variants
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [EmDash](https://github.com/emdash-cms/emdash) plugin for sites on Cloudflare
4
+ that resizes each image once and stores the resized copies in R2. Pages point their
5
+ `srcset` at the stored files.
6
+
7
+ By default, EmDash resizes media through the Cloudflare Images binding on every
8
+ request to `/_image` and sends `Cache-Control: max-age=0`, so every image view runs
9
+ your Worker and calls the Images service.
10
+
11
+ ## Benefits
12
+
13
+ - **Lower Cloudflare Images usage.** Each width is resized once per version of an
14
+ image. Cloudflare bills unique resizes again each month, so without the plugin a
15
+ site pays for the same resizes every month.
16
+ - **Fewer Worker requests.** With a public variants bucket, image requests are
17
+ served by R2 and the Cloudflare cache without invoking your Worker.
18
+ - **Faster repeat views.** Stored copies are sent with a one-year `immutable`
19
+ cache header, so browsers and the Cloudflare cache keep them. A replaced image
20
+ gets new URLs, so nobody sees a stale copy.
21
+ - **Little template work.** `Image` takes the same props as EmDash's. Images in
22
+ rich-text content and dark-mode variants are handled with no template changes.
23
+ - **Private originals.** The resized copies can live in their own public bucket
24
+ while the media bucket stays private.
25
+
26
+ **Status:** version 0.1.0, early release.
27
+
28
+ ## How it works
29
+
30
+ - **Rendering.** The plugin's `Image` component reads
31
+ `_variants/<storageKey>/manifest.json` from the bucket and checks it against the
32
+ original's current etag. If they match, it renders an `<img>` whose `srcset` lists
33
+ the stored files.
34
+ - **First view.** If there is no manifest yet, or the original has changed, that
35
+ request renders with EmDash's own image component. After the response, the plugin
36
+ creates every configured width with the Images binding and writes the manifest
37
+ last. The next render uses the stored files.
38
+ - **Images in body content.** The plugin registers its own Portable Text `image`
39
+ block, so images inside rich text work the same way with no template changes.
40
+ - **Dark-mode images.** When an image has a `darkVariant`, both images are served
41
+ from stored files. EmDash's light and dark classes switch between them.
42
+ - **Replaced media.** EmDash overwrites a replaced file under the same key, which
43
+ gives it a new etag. Variants live under `<storageKey>/<etag>/`, so a replaced
44
+ image gets new URLs and cached copies of the old version are never shown.
45
+ - **Cleanup.** A daily job at 03:17 UTC deletes variants whose original was deleted
46
+ or replaced. The first media upload schedules it.
47
+
48
+ ## Requirements
49
+
50
+ - EmDash 1.1 or later with R2 storage from `@emdash-cms/cloudflare`
51
+ - `@astrojs/cloudflare` with its default Images binding (`IMAGES`)
52
+
53
+ This is a **native** EmDash plugin. It is registered under `plugins` and runs with
54
+ the site's authority, because it needs the R2 and Images bindings and ships Astro
55
+ components. EmDash's plugin registry distributes only sandboxed plugins, so this
56
+ package is distributed through npm.
57
+
58
+ ## Install
59
+
60
+ ```sh
61
+ npm install emdash-plugin-r2-variants
62
+ ```
63
+
64
+ Add a second R2 bucket for the resized copies in `wrangler.jsonc`:
65
+
66
+ ```jsonc
67
+ "r2_buckets": [
68
+ { "binding": "MEDIA", "bucket_name": "my-site-media" },
69
+ { "binding": "IMAGE_VARIANTS", "bucket_name": "my-site-variants" }
70
+ ]
71
+ ```
72
+
73
+ Register the plugin in `astro.config.mjs`:
74
+
75
+ ```js
76
+ import { r2 } from "@emdash-cms/cloudflare";
77
+ import emdash from "emdash/astro";
78
+ import { r2Variants } from "emdash-plugin-r2-variants";
79
+
80
+ export default defineConfig({
81
+ integrations: [
82
+ emdash({
83
+ storage: r2({ binding: "MEDIA" }),
84
+ plugins: [
85
+ r2Variants({
86
+ binding: "MEDIA",
87
+ variantsBinding: "IMAGE_VARIANTS",
88
+ publicUrl: "https://images.example.com",
89
+ }),
90
+ ],
91
+ }),
92
+ ],
93
+ });
94
+ ```
95
+
96
+ ### Keep the media bucket private
97
+
98
+ Give the variants their own bucket and attach the public domain only to that
99
+ bucket. A public domain on the media bucket would expose every object in it: the
100
+ full-size originals, which may carry EXIF location data, and EmDash's private
101
+ `transfers/` folder of import and export files. EmDash's own media route refuses
102
+ those paths, but a bucket domain bypasses the route.
103
+
104
+ Without `variantsBinding`, the copies are stored in the media bucket under
105
+ `_variants/`. Where visitors load them from depends on `publicUrl`:
106
+
107
+ | `variantsBinding` | `publicUrl` | Copies stored in | Visitors load them from |
108
+ | ----------------- | ----------- | ---------------- | ---------------------------------------------------- |
109
+ | set | set | variants bucket | the variants bucket's public domain, with no Worker |
110
+ | set | not set | — | the plugin refuses to start |
111
+ | not set | not set | media bucket | EmDash's media route, through the Worker |
112
+ | not set | set | media bucket | the media bucket's public domain (unsafe, see below) |
113
+
114
+ The first row is the recommended setup.
115
+
116
+ With neither option set, the media bucket stays private. Each image view still
117
+ runs the Worker and reads from R2, though it never resizes. EmDash's media route
118
+ sends `Cache-Control: max-age=0, must-revalidate`, so browsers check each image
119
+ again on every page load. This mode suits local development.
120
+
121
+ The last row works only if the media bucket has a public domain, which exposes
122
+ every file in it as described above. Don't use it.
123
+
124
+ In templates, import `Image` from the plugin instead of from `emdash/ui`. It takes
125
+ the same props.
126
+
127
+ ```astro
128
+ ---
129
+ import { Image } from "emdash-plugin-r2-variants/astro";
130
+ ---
131
+ <Image image={post.data.featured_image} width={960} />
132
+ ```
133
+
134
+ ## Options
135
+
136
+ | Option | Default | Description |
137
+ | ----------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
138
+ | `binding` | `"MEDIA"` | R2 binding that EmDash stores media in. |
139
+ | `variantsBinding` | media bucket | R2 binding for the resized copies. Requires `publicUrl`. |
140
+ | `publicUrl` | `""` | `https://` custom domain attached to the variants bucket. When empty, EmDash's media route serves the copies through the Worker. |
141
+ | `widths` | `[320, 640, 960, 1280, 1920]` | Widths to create, up to 4000. Widths larger than the original are skipped, and the original width is always added. |
142
+ | `format` | `"webp"` | `"webp"` or `"avif"`. |
143
+ | `quality` | `85` | Encoder quality. |
144
+
145
+ ## Production setup
146
+
147
+ 1. Create the variants bucket. In the Cloudflare dashboard, connect a custom domain
148
+ to it (for example `images.example.com`) and set it as `publicUrl`. Avoid `r2.dev`
149
+ URLs, which are rate limited. Leave the media bucket without any public access.
150
+ 2. Add a Cache Rule for that hostname that caches everything with a long edge TTL.
151
+ Each variant URL contains the original's etag, so the file behind a URL never
152
+ changes.
153
+ 3. Add a cron trigger to `wrangler.jsonc` so EmDash's scheduled handler runs plugin
154
+ jobs, for example `"triggers": { "crons": ["* * * * *"] }`.
155
+
156
+ ## Security
157
+
158
+ - Storage keys are read from content, so the plugin accepts only keys that
159
+ EmDash's own media route would serve (`isSafeTransformKey` from EmDash). Files
160
+ in private folders such as `transfers/` are never resized or published.
161
+ - Focal points and placeholder colours also come from content. Only numbers from
162
+ 0 to 1 and hex colours reach the `style` attribute.
163
+ - WebP and AVIF output carries no EXIF metadata, so GPS data in originals never
164
+ reaches the public copies.
165
+ - Originals larger than 25 MB, and formats other than JPEG, PNG, WebP and AVIF,
166
+ are not resized. EmDash renders them as before.
167
+ - The plugin adds no routes. Resizing happens only for images that content
168
+ references, when a page renders.
169
+ - Options are checked when `astro.config.mjs` loads. `publicUrl` must use
170
+ `https://`.
171
+
172
+ ## Verify it works
173
+
174
+ 1. Open a page with images twice. The second time, the `<img>` `src` and `srcset`
175
+ point at `/_variants/.../<width>.webp`, on your `publicUrl` domain if one is set.
176
+ 2. List the variants bucket: `wrangler r2 object list <variants-bucket> --prefix _variants/`.
177
+ 3. After the first media upload, the dashboard's scheduled tasks include
178
+ `r2-variants` / `cleanup`.
179
+
180
+ ## Costs
181
+
182
+ - Each width is resized once per version of an image, rather than once a month or
183
+ once per data centre.
184
+ - With `publicUrl` set, visitors' image requests are served by R2 and the Cloudflare
185
+ cache, without a Worker invocation.
186
+ - Each page render makes two R2 reads per image: one for the manifest and one for
187
+ the original's metadata. Caching rendered pages keeps this small.
188
+
189
+ ## Limitations
190
+
191
+ - These are rendered by EmDash as before: linked images in body content, gallery
192
+ blocks, and images from external media providers.
193
+ - If a site passes its own `image` component to `<PortableText>`, that component
194
+ takes precedence over the plugin's.
195
+ - Pages served from stored files show the dominant-colour placeholder. They do not
196
+ show the blurhash placeholder.
197
+ - SVG and GIF files are served unchanged.
198
+ - Cloudflare only: resizing uses the Images binding.
199
+
200
+ ## Development
201
+
202
+ ```sh
203
+ npm test # node --test; needs no Cloudflare account
204
+ ```
205
+
206
+ | Path | Contents |
207
+ | -------------------------- | -------------------------------------------------------------- |
208
+ | `src/index.js` | Plugin descriptor for `astro.config.mjs`. Safe to load in Node. |
209
+ | `src/plugin.js` | Runtime plugin: hooks and the cleanup job. |
210
+ | `src/variants.js` | Resize, manifest, cleanup and `srcset` logic, with tests. |
211
+ | `src/runtime.js` | Worker bindings shared by the components. |
212
+ | `src/astro/` | `Image`, the Portable Text `ImageBlock`, and `blockComponents`. |
213
+
214
+ ## License
215
+
216
+ MIT
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "emdash-plugin-r2-variants",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Image variants plugin for EmDash CMS - stores each resized image in R2 once and serves it without a Worker transform",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./plugin": "./src/plugin.js",
14
+ "./astro": "./src/astro/index.ts",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "!src/**/*.test.js",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "keywords": [
23
+ "emdash",
24
+ "emdash-plugin",
25
+ "cms",
26
+ "plugin",
27
+ "images",
28
+ "responsive-images",
29
+ "r2",
30
+ "cloudflare",
31
+ "astro"
32
+ ],
33
+ "author": "RJS Tech Solutions LLP",
34
+ "license": "MIT",
35
+ "peerDependencies": {
36
+ "astro": ">=6",
37
+ "emdash": ">=1.1.0"
38
+ },
39
+ "scripts": {
40
+ "test": "node --test src/"
41
+ },
42
+ "devDependencies": {
43
+ "emdash": ">=1.1.0"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/rjstech2020/emdash-plugin-r2-variants.git"
48
+ },
49
+ "homepage": "https://github.com/rjstech2020/emdash-plugin-r2-variants#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/rjstech2020/emdash-plugin-r2-variants/issues"
52
+ }
53
+ }
@@ -0,0 +1,64 @@
1
+ ---
2
+ /**
3
+ * Drop-in replacement for `Image` from "emdash/ui".
4
+ *
5
+ * When stored variants exist for the current original (and for the dark-mode
6
+ * image, if there is one), renders plain <img> tags whose srcset points at
7
+ * them, so a visitor's request never reaches the Worker when `publicUrl` is
8
+ * set. Otherwise renders EmDash's own Image for this request; the missing
9
+ * variants are created after the response.
10
+ *
11
+ * Dark-mode switching uses the emdash-image--light/--dark classes. Their CSS
12
+ * ships with EmDash's Image, which this component imports.
13
+ */
14
+ import type { ComponentProps } from "astro/types";
15
+ import { Image as EmDashImage } from "emdash/ui";
16
+
17
+ import { lookup, storageKeyOf } from "../runtime.js";
18
+ import { imgAttrs } from "../variants.js";
19
+
20
+ type Props = ComponentProps<typeof EmDashImage>;
21
+
22
+ const props = Astro.props;
23
+ const { image, darkVariant, alt, width, height, priority, placeholder = true, class: className, id, ...attrs } = props;
24
+
25
+ const asValue = (v: unknown) => (v && typeof v === "object" ? (v as Record<string, any>) : null);
26
+ const light = asValue(image);
27
+ const dark = asValue(darkVariant ?? light?.darkVariant);
28
+
29
+ const lightKey = storageKeyOf(light);
30
+ const darkKey = dark ? storageKeyOf(dark) : undefined;
31
+ // Every image shown must be local media; otherwise EmDash renders the whole thing.
32
+ const usable = lightKey && (!dark || darkKey);
33
+ const [lightManifest, darkManifest] = usable
34
+ ? await Promise.all([lookup(lightKey), darkKey ? lookup(darkKey) : null])
35
+ : [null, null];
36
+ const ready = lightManifest && (!dark || darkManifest);
37
+
38
+ function render(value: Record<string, any>, manifest: any, variantClass: string | undefined, elementId: string | undefined) {
39
+ const a = imgAttrs(manifest, {
40
+ width,
41
+ height,
42
+ focalX: value.focalX,
43
+ focalY: value.focalY,
44
+ dominantColor: placeholder ? (value.dominantColor ?? value.meta?.dominantColor) : undefined,
45
+ });
46
+ return {
47
+ ...a,
48
+ id: elementId,
49
+ alt: alt ?? light?.alt ?? "",
50
+ loading: priority ? "eager" : "lazy",
51
+ fetchpriority: priority ? "high" : undefined,
52
+ decoding: "async",
53
+ class: [variantClass, className].filter(Boolean).join(" ") || undefined,
54
+ ...attrs,
55
+ };
56
+ }
57
+
58
+ const lightImg = ready ? render(light!, lightManifest, dark ? "emdash-image--light" : undefined, id) : null;
59
+ const darkImg = ready && dark ? render(dark, darkManifest, "emdash-image--dark", id ? `${id}--dark` : undefined) : null;
60
+ ---
61
+
62
+ {lightImg ? <img {...lightImg} /> : <EmDashImage {...props} />}
63
+ {darkImg && <img {...darkImg} />}
64
+
@@ -0,0 +1,91 @@
1
+ ---
2
+ /**
3
+ * Portable Text `image` block. Registered through the plugin's componentsEntry,
4
+ * so every <PortableText> on the site uses it without template changes.
5
+ *
6
+ * Renders EmDash's markup (figure, alignment, caption) with stored variants.
7
+ * Falls back to EmDash's own block for linked images, external providers, and
8
+ * images whose variants don't exist yet.
9
+ */
10
+ import { PTImage } from "emdash/ui";
11
+
12
+ import { lookup, storageKeyOf } from "../runtime.js";
13
+ import { imgAttrs } from "../variants.js";
14
+
15
+ const props = Astro.props;
16
+ const { node, placeholder = true } = props;
17
+ const asset = node?.asset;
18
+
19
+ const ALIGNMENTS = new Set(["left", "center", "right", "wide", "full"]);
20
+ const positive = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
21
+ const key = node?.link ? undefined : storageKeyOf(asset);
22
+ const manifest = key ? await lookup(key) : null;
23
+
24
+ const img = manifest
25
+ ? {
26
+ ...imgAttrs(manifest, {
27
+ width: positive(node.displayWidth),
28
+ height: positive(node.displayHeight),
29
+ dominantColor: placeholder ? (node.dominantColor ?? asset.meta?.dominantColor) : undefined,
30
+ }),
31
+ alt: node.alt ?? "",
32
+ title: node.title,
33
+ loading: "lazy",
34
+ decoding: "async",
35
+ }
36
+ : null;
37
+ ---
38
+
39
+ {
40
+ img ? (
41
+ <figure class:list={["emdash-image", ALIGNMENTS.has(node.alignment) && `emdash-image--align-${node.alignment}`]}>
42
+ <img {...img} />
43
+ {node.caption && <figcaption>{node.caption}</figcaption>}
44
+ </figure>
45
+ ) : (
46
+ <PTImage {...props} />
47
+ )
48
+ }
49
+
50
+ <style>
51
+ /* Copied from EmDash's Portable Text image block, whose styles are scoped to it. */
52
+ .emdash-image {
53
+ margin: 1.5rem 0;
54
+ }
55
+ .emdash-image figcaption {
56
+ font-size: 0.875rem;
57
+ color: var(--emdash-caption-color, var(--color-muted, #666));
58
+ margin-top: 0.5rem;
59
+ text-align: center;
60
+ }
61
+ .emdash-image--align-left,
62
+ .emdash-image--align-right {
63
+ width: fit-content;
64
+ max-width: 50%;
65
+ }
66
+ .emdash-image--align-left {
67
+ float: left;
68
+ margin: 0 1.5rem 1rem 0;
69
+ }
70
+ .emdash-image--align-right {
71
+ float: right;
72
+ margin: 0 0 1rem 1.5rem;
73
+ }
74
+ .emdash-image--align-center {
75
+ width: fit-content;
76
+ margin-inline: auto;
77
+ }
78
+ .emdash-image--align-wide,
79
+ .emdash-image--align-full {
80
+ width: 100%;
81
+ }
82
+ @media (max-width: 640px) {
83
+ .emdash-image--align-left,
84
+ .emdash-image--align-right {
85
+ float: none;
86
+ width: 100%;
87
+ max-width: 100%;
88
+ margin-inline: 0;
89
+ }
90
+ }
91
+ </style>
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Astro components for sites using the plugin.
3
+ *
4
+ * `blockComponents` is the plugin's componentsEntry: EmDash merges it over its
5
+ * own Portable Text components, so body-content images need no template change.
6
+ *
7
+ * @example
8
+ * ```astro
9
+ * ---
10
+ * import { Image } from "emdash-plugin-r2-variants/astro";
11
+ * ---
12
+ * <Image image={post.data.featured_image} width={960} />
13
+ * ```
14
+ */
15
+ import ImageComponent from "./Image.astro";
16
+ import ImageBlockComponent from "./ImageBlock.astro";
17
+
18
+ export const blockComponents = { image: ImageBlockComponent };
19
+
20
+ export { ImageComponent as Image, ImageBlockComponent as ImageBlock };
package/src/index.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { PluginDescriptor } from "emdash";
2
+
3
+ export interface R2VariantsOptions {
4
+ /** R2 binding EmDash stores media in. Default "MEDIA". */
5
+ binding?: string;
6
+ /**
7
+ * R2 binding for the resized copies. Recommended when the copies get a public
8
+ * domain, so the media bucket (originals, EmDash's private files) stays private.
9
+ * Requires `publicUrl`. Default: the media bucket.
10
+ */
11
+ variantsBinding?: string;
12
+ /** https:// custom domain attached to the variants bucket, e.g. "https://images.example.com". Empty: serve through EmDash's media route. */
13
+ publicUrl?: string;
14
+ /** Widths to create, in pixels, up to 4000. Default [320, 640, 960, 1280, 1920]. */
15
+ widths?: number[];
16
+ /** Output format. Default "webp". */
17
+ format?: "webp" | "avif";
18
+ /** Encoder quality, 1-100. Default: EmDash's transform quality (85). */
19
+ quality?: number;
20
+ }
21
+
22
+ export function r2Variants(options?: R2VariantsOptions): PluginDescriptor<R2VariantsOptions>;
23
+ export default r2Variants;
package/src/index.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Plugin descriptor for astro.config.mjs. Validates options when the config
3
+ * loads, so a broken setup fails the build rather than the first request.
4
+ */
5
+ import { validate } from "./variants.js";
6
+
7
+ /**
8
+ * @param {Partial<import("./variants.js").Config>} [options]
9
+ * @returns {import("emdash").PluginDescriptor}
10
+ */
11
+ export function r2Variants(options = {}) {
12
+ validate(options);
13
+ return {
14
+ id: "r2-variants",
15
+ version: "0.1.0",
16
+ format: "native",
17
+ entrypoint: "emdash-plugin-r2-variants/plugin",
18
+ componentsEntry: "emdash-plugin-r2-variants/astro",
19
+ options,
20
+ };
21
+ }
22
+
23
+ export default r2Variants;
package/src/plugin.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Native plugin runtime. EmDash imports `createPlugin` from here and calls it
3
+ * with the descriptor's options when the site starts.
4
+ */
5
+ import { env } from "cloudflare:workers";
6
+ import { definePlugin } from "emdash";
7
+
8
+ import { bindings, cleanup, configure } from "./variants.js";
9
+
10
+ const scheduleCleanup = async (_event, ctx) => {
11
+ await ctx.cron.schedule("cleanup", { schedule: "17 3 * * *" });
12
+ };
13
+
14
+ /** @param {Partial<import("./variants.js").Config>} options */
15
+ export function createPlugin(options = {}) {
16
+ configure(options);
17
+ return definePlugin({
18
+ id: "r2-variants",
19
+ version: "0.1.0",
20
+ // media:read is what EmDash requires for media:afterUpload.
21
+ capabilities: ["media:read"],
22
+ hooks: {
23
+ // Plugins declared in astro.config never get plugin:activate, so the
24
+ // first upload schedules the job too. schedule() upserts by name.
25
+ "plugin:activate": scheduleCleanup,
26
+ "media:afterUpload": scheduleCleanup,
27
+ cron: async (event, ctx) => {
28
+ if (event.name !== "cleanup") return;
29
+ const { media, variants } = bindings();
30
+ const removed = await cleanup(env[media], env[variants]);
31
+ ctx.log.info("Removed stale image variants", { removed });
32
+ },
33
+ },
34
+ });
35
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Worker-side glue shared by the Astro components: finds the stored variants
3
+ * for a media value, or schedules their creation after the response.
4
+ */
5
+ import { env, waitUntil } from "cloudflare:workers";
6
+ import { matchInternalMediaKey } from "emdash/media/image-endpoint";
7
+
8
+ import { bindings, generate, isSafeKey, readManifest } from "./variants.js";
9
+
10
+ /** Resize jobs in flight in this isolate, so parallel first views share one. */
11
+ const pending = new Map();
12
+
13
+ /**
14
+ * Storage key of a local media value. Field values carry `meta.storageKey`;
15
+ * Portable Text assets carry the media URL. Both come from content, so only
16
+ * keys EmDash itself would serve are accepted.
17
+ */
18
+ export function storageKeyOf(value) {
19
+ if (!value || typeof value !== "object" || (value.provider ?? "local") !== "local") return undefined;
20
+ const key = value.meta?.storageKey ?? matchInternalMediaKey(value.url);
21
+ return isSafeKey(key) ? key : undefined;
22
+ }
23
+
24
+ /**
25
+ * Manifest for the given storage key, or null. On a miss the variants are
26
+ * created after the response, so the next render can use them.
27
+ * @returns {Promise<import("./variants.js").Manifest | null>}
28
+ */
29
+ export async function lookup(storageKey) {
30
+ const names = bindings();
31
+ const media = env[names.media];
32
+ const variants = env[names.variants];
33
+ if (!isSafeKey(storageKey) || !media || !variants) return null;
34
+ const manifest = await readManifest(media, variants, storageKey).catch(() => null);
35
+ if (manifest) return manifest;
36
+
37
+ const images = env[globalThis.__ASTRO_IMAGES_BINDING_NAME || "IMAGES"];
38
+ if (images && !pending.has(storageKey)) {
39
+ const job = generate(media, variants, images, storageKey)
40
+ .catch((error) => console.error("[r2-variants] generate failed", storageKey, error))
41
+ .finally(() => pending.delete(storageKey));
42
+ pending.set(storageKey, job);
43
+ waitUntil(job);
44
+ }
45
+ return null;
46
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Core logic. Free of `cloudflare:workers`, so it loads in astro.config.mjs and
3
+ * under `node --test`.
4
+ *
5
+ * Layout in the variants bucket (the media bucket unless `variantsBinding` is set):
6
+ * _variants/<storageKey>/manifest.json which variants exist, for which original
7
+ * _variants/<storageKey>/<etag>/<width>.<format> one resized copy per width
8
+ *
9
+ * The original's R2 etag is part of every variant path. EmDash's "replace media"
10
+ * overwrites the original under the same key, which changes the etag, so a
11
+ * replaced image gets new URLs and long-lived CDN copies never go stale.
12
+ */
13
+ import {
14
+ DEFAULT_TRANSFORM_QUALITY,
15
+ IMMUTABLE_IMAGE_CACHE,
16
+ MAX_TRANSFORM_DIMENSION,
17
+ isSafeTransformKey,
18
+ } from "emdash/media/image-endpoint";
19
+
20
+ /** @typedef {{ binding: string, variantsBinding: string, publicUrl: string, widths: number[], format: "webp" | "avif", quality: number }} Config */
21
+ /** @typedef {{ key: string, etag: string, format: string, width: number, height: number, widths: number[] }} Manifest */
22
+
23
+ const PREFIX = "_variants";
24
+ // Both output formats drop all EXIF metadata, so GPS data in originals never reaches the public copies.
25
+ const FORMATS = { webp: "image/webp", avif: "image/avif" };
26
+ const RESIZABLE = new Set(["image/jpeg", "image/png", "image/webp", "image/avif"]);
27
+ // Originals are buffered in Worker memory; larger files keep EmDash's own rendering.
28
+ const MAX_SOURCE_BYTES = 25 * 1024 * 1024;
29
+ const HEX_COLOR = /^#[0-9a-f]{3,8}$/i;
30
+
31
+ /** @type {Config} */
32
+ export const config = {
33
+ binding: "MEDIA",
34
+ variantsBinding: "",
35
+ publicUrl: "",
36
+ widths: [320, 640, 960, 1280, 1920],
37
+ format: "webp",
38
+ quality: DEFAULT_TRANSFORM_QUALITY,
39
+ };
40
+
41
+ /**
42
+ * Options merged over the defaults. Throws on a setup that would break images.
43
+ * @param {Partial<Config>} options
44
+ * @returns {Config}
45
+ */
46
+ export function validate(options = {}) {
47
+ const c = { ...config, ...options, publicUrl: (options.publicUrl ?? config.publicUrl).replace(/\/+$/, "") };
48
+ const fail = (message) => {
49
+ throw new Error(`[r2-variants] ${message}`);
50
+ };
51
+ if (c.publicUrl && !c.publicUrl.startsWith("https://")) fail("`publicUrl` must start with https://.");
52
+ // EmDash's media route only reads the media bucket, so a separate bucket needs its own domain.
53
+ if (c.variantsBinding && c.variantsBinding !== c.binding && !c.publicUrl) {
54
+ fail("`variantsBinding` requires `publicUrl`: the custom domain attached to that bucket.");
55
+ }
56
+ if (!FORMATS[c.format]) fail('`format` must be "webp" or "avif".');
57
+ if (!Number.isInteger(c.quality) || c.quality < 1 || c.quality > 100) fail("`quality` must be an integer from 1 to 100.");
58
+ if (!c.widths.length || !c.widths.every((w) => Number.isInteger(w) && w > 0 && w <= MAX_TRANSFORM_DIMENSION)) {
59
+ fail(`\`widths\` must be integers from 1 to ${MAX_TRANSFORM_DIMENSION}.`);
60
+ }
61
+ return c;
62
+ }
63
+
64
+ /** @param {Partial<Config>} options */
65
+ export function configure(options) {
66
+ Object.assign(config, validate(options));
67
+ }
68
+
69
+ /** Binding names for originals and variants. */
70
+ export const bindings = () => ({ media: config.binding, variants: config.variantsBinding || config.binding });
71
+
72
+ /** Storage keys EmDash itself would serve: flat `{ulid}{ext}` names, never private folders such as `transfers/`. */
73
+ export const isSafeKey = (key) => typeof key === "string" && isSafeTransformKey(key);
74
+
75
+ const manifestKey = (key) => `${PREFIX}/${key}/manifest.json`;
76
+
77
+ /**
78
+ * The manifest for `key`, or null when variants are missing or were made from
79
+ * an earlier version of the original.
80
+ * @returns {Promise<Manifest | null>}
81
+ */
82
+ export async function readManifest(media, variants, key) {
83
+ const [original, stored] = await Promise.all([media.head(key), variants.get(manifestKey(key))]);
84
+ if (!original || !stored) return null;
85
+ const m = await stored.json();
86
+ return m.etag === original.etag ? { ...m, key } : null;
87
+ }
88
+
89
+ /**
90
+ * Resize the original once per configured width and store the results.
91
+ * Widths larger than the original are skipped; the original width (capped at
92
+ * EmDash's transform limit) is always included.
93
+ * @returns {Promise<Manifest | null>} null when the original is missing, too large, or not a resizable raster.
94
+ */
95
+ export async function generate(media, variants, images, key) {
96
+ const original = await media.get(key);
97
+ if (!original) return null;
98
+ if (!RESIZABLE.has(original.httpMetadata?.contentType) || original.size > MAX_SOURCE_BYTES) {
99
+ await original.body?.cancel();
100
+ return null;
101
+ }
102
+ const bytes = await original.arrayBuffer();
103
+ const { width, height } = await images.info(new Response(bytes).body);
104
+ if (!width || !height) return null;
105
+
106
+ const largest = Math.min(width, MAX_TRANSFORM_DIMENSION);
107
+ const widths = [...new Set([...config.widths.filter((w) => w < largest), largest])].sort((a, b) => a - b);
108
+ const { etag } = original;
109
+ const { format } = config;
110
+ for (const w of widths) {
111
+ const out = await images
112
+ .input(new Response(bytes).body)
113
+ .transform({ width: w })
114
+ .output({ format: FORMATS[format], quality: config.quality });
115
+ // R2 needs a known length, so buffer instead of streaming.
116
+ await variants.put(`${PREFIX}/${key}/${etag}/${w}.${format}`, await out.response().arrayBuffer(), {
117
+ httpMetadata: { contentType: FORMATS[format], cacheControl: IMMUTABLE_IMAGE_CACHE },
118
+ });
119
+ }
120
+
121
+ const manifest = { etag, format, width, height, widths };
122
+ // Written last: a manifest only exists once every variant it lists does.
123
+ await variants.put(manifestKey(key), JSON.stringify(manifest), {
124
+ httpMetadata: { contentType: "application/json", cacheControl: "no-cache" },
125
+ });
126
+ return { ...manifest, key };
127
+ }
128
+
129
+ /**
130
+ * Delete variants whose original was deleted or replaced.
131
+ * @returns {Promise<number>} objects removed
132
+ */
133
+ export async function cleanup(media, variants) {
134
+ const prefix = `${PREFIX}/`;
135
+ const etags = new Map(); // storageKey -> current etag, or null when the original is gone
136
+ const currentEtag = async (key) => {
137
+ if (!etags.has(key)) etags.set(key, (await media.head(key))?.etag ?? null);
138
+ return etags.get(key);
139
+ };
140
+
141
+ let removed = 0;
142
+ let cursor;
143
+ do {
144
+ const page = await variants.list({ prefix, cursor });
145
+ const stale = [];
146
+ for (const { key: name } of page.objects) {
147
+ const [key, second, third] = name.slice(prefix.length).split("/");
148
+ const etag = await currentEtag(key);
149
+ // <key>/manifest.json stays while the original exists; <key>/<etag>/<file> only for the current etag.
150
+ if (etag === null || (third !== undefined && second !== etag)) stale.push(name);
151
+ }
152
+ if (stale.length) await variants.delete(stale);
153
+ removed += stale.length;
154
+ cursor = page.truncated ? page.cursor : undefined;
155
+ } while (cursor);
156
+ return removed;
157
+ }
158
+
159
+ /** Public URL of one variant. Without `publicUrl`, EmDash's media route serves it. */
160
+ function variantUrl(m, width) {
161
+ const path = `${PREFIX}/${m.key}/${m.etag}/${width}.${m.format}`;
162
+ return config.publicUrl ? `${config.publicUrl}/${path}` : `/_emdash/api/media/file/${path}`;
163
+ }
164
+
165
+ /**
166
+ * srcset/src/sizes for an image displayed `displayWidth` CSS pixels wide.
167
+ * Lists variants up to 2x the display width for high-density screens.
168
+ */
169
+ export function responsive(m, displayWidth) {
170
+ const fits = m.widths.filter((w) => w <= displayWidth * 2);
171
+ const src = m.widths.find((w) => w >= displayWidth) ?? m.widths.at(-1);
172
+ return {
173
+ src: variantUrl(m, src),
174
+ srcset: (fits.length ? fits : [m.widths[0]]).map((w) => `${variantUrl(m, w)} ${w}w`).join(", "),
175
+ sizes: `(min-width: ${displayWidth}px) ${displayWidth}px, 100vw`,
176
+ };
177
+ }
178
+
179
+ const unit = (v) => typeof v === "number" && v >= 0 && v <= 1;
180
+ const percent = (v) => `${Math.round(v * 10000) / 100}%`;
181
+
182
+ /**
183
+ * <img> attributes for a stored image. `width`/`height` are the display size;
184
+ * either may be omitted and is then derived from the original's aspect ratio.
185
+ * A display box with a different aspect ratio is filled with object-fit: cover.
186
+ * Focal point and colour come from content, so only well-formed values reach the style attribute.
187
+ */
188
+ export function imgAttrs(m, { width, height, focalX, focalY, dominantColor } = {}) {
189
+ const ratio = m.width / m.height;
190
+ const w = width ?? (height ? Math.round(height * ratio) : m.width);
191
+ const h = height ?? Math.round(w / ratio);
192
+ const style = [
193
+ "max-width:100%;height:auto",
194
+ Math.abs(w / h - ratio) > 0.01 && "object-fit:cover",
195
+ unit(focalX) && unit(focalY) && `object-position:${percent(focalX)} ${percent(focalY)}`,
196
+ HEX_COLOR.test(dominantColor ?? "") && `background-color:${dominantColor}`,
197
+ ]
198
+ .filter(Boolean)
199
+ .join(";");
200
+ return { ...responsive(m, w), width: w, height: h, style };
201
+ }