emdash-plugin-r2-variants 0.0.0-stage → 0.1.1
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 +20 -0
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/package.json +51 -4
- package/src/astro/Image.astro +64 -0
- package/src/astro/ImageBlock.astro +91 -0
- package/src/astro/index.ts +20 -0
- package/src/index.d.ts +23 -0
- package/src/index.js +23 -0
- package/src/plugin.js +35 -0
- package/src/runtime.js +46 -0
- package/src/variants.js +201 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# emdash-plugin-r2-variants
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- Docs only, no code changes. The README now lists the declared capability
|
|
6
|
+
(`media:read`) and explains the native trust boundary, as EmDash's guide to
|
|
7
|
+
distributing native plugins asks.
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
- First release: `Image` component, Portable Text `image` block, dark-mode
|
|
12
|
+
variants, and a daily cleanup job.
|
|
13
|
+
- `variantsBinding` option: store resized copies in a separate public bucket so
|
|
14
|
+
the media bucket stays private.
|
|
15
|
+
- Security: accept only storage keys EmDash itself would serve, validate focal
|
|
16
|
+
points and colours from content, skip originals over 25 MB, validate options.
|
|
17
|
+
- Uses EmDash's exported helpers and constants instead of copies. Removed the
|
|
18
|
+
`prefix` option.
|
|
19
|
+
- Capabilities: declares `media:read`, which EmDash requires for the
|
|
20
|
+
`media:afterUpload` hook that schedules cleanup. No allowed hosts.
|
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,230 @@
|
|
|
1
|
-
#
|
|
1
|
+
# emdash-plugin-r2-variants
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
## Capabilities
|
|
157
|
+
|
|
158
|
+
| Capability | Used by |
|
|
159
|
+
| ------------ | ----------------------------------------------------------------------------------------- |
|
|
160
|
+
| `media:read` | The `media:afterUpload` hook, which schedules the daily cleanup job. EmDash requires this capability for that hook. |
|
|
161
|
+
|
|
162
|
+
The plugin declares no allowed hosts and makes no network requests.
|
|
163
|
+
|
|
164
|
+
Capabilities gate only the `ctx` APIs that EmDash passes to the plugin. As a
|
|
165
|
+
native plugin, this code also has access to everything the site's Worker has,
|
|
166
|
+
including all bindings and environment variables. That access is why the
|
|
167
|
+
plugin can read and write R2 directly. Review it as you would the site's own
|
|
168
|
+
code.
|
|
169
|
+
|
|
170
|
+
## Security
|
|
171
|
+
|
|
172
|
+
- Storage keys are read from content, so the plugin accepts only keys that
|
|
173
|
+
EmDash's own media route would serve (`isSafeTransformKey` from EmDash). Files
|
|
174
|
+
in private folders such as `transfers/` are never resized or published.
|
|
175
|
+
- Focal points and placeholder colours also come from content. Only numbers from
|
|
176
|
+
0 to 1 and hex colours reach the `style` attribute.
|
|
177
|
+
- WebP and AVIF output carries no EXIF metadata, so GPS data in originals never
|
|
178
|
+
reaches the public copies.
|
|
179
|
+
- Originals larger than 25 MB, and formats other than JPEG, PNG, WebP and AVIF,
|
|
180
|
+
are not resized. EmDash renders them as before.
|
|
181
|
+
- The plugin adds no routes. Resizing happens only for images that content
|
|
182
|
+
references, when a page renders.
|
|
183
|
+
- Options are checked when `astro.config.mjs` loads. `publicUrl` must use
|
|
184
|
+
`https://`.
|
|
185
|
+
|
|
186
|
+
## Verify it works
|
|
187
|
+
|
|
188
|
+
1. Open a page with images twice. The second time, the `<img>` `src` and `srcset`
|
|
189
|
+
point at `/_variants/.../<width>.webp`, on your `publicUrl` domain if one is set.
|
|
190
|
+
2. List the variants bucket: `wrangler r2 object list <variants-bucket> --prefix _variants/`.
|
|
191
|
+
3. After the first media upload, the dashboard's scheduled tasks include
|
|
192
|
+
`r2-variants` / `cleanup`.
|
|
193
|
+
|
|
194
|
+
## Costs
|
|
195
|
+
|
|
196
|
+
- Each width is resized once per version of an image, rather than once a month or
|
|
197
|
+
once per data centre.
|
|
198
|
+
- With `publicUrl` set, visitors' image requests are served by R2 and the Cloudflare
|
|
199
|
+
cache, without a Worker invocation.
|
|
200
|
+
- Each page render makes two R2 reads per image: one for the manifest and one for
|
|
201
|
+
the original's metadata. Caching rendered pages keeps this small.
|
|
202
|
+
|
|
203
|
+
## Limitations
|
|
204
|
+
|
|
205
|
+
- These are rendered by EmDash as before: linked images in body content, gallery
|
|
206
|
+
blocks, and images from external media providers.
|
|
207
|
+
- If a site passes its own `image` component to `<PortableText>`, that component
|
|
208
|
+
takes precedence over the plugin's.
|
|
209
|
+
- Pages served from stored files show the dominant-colour placeholder. They do not
|
|
210
|
+
show the blurhash placeholder.
|
|
211
|
+
- SVG and GIF files are served unchanged.
|
|
212
|
+
- Cloudflare only: resizing uses the Images binding.
|
|
213
|
+
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
npm test # node --test; needs no Cloudflare account
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| Path | Contents |
|
|
221
|
+
| -------------------------- | -------------------------------------------------------------- |
|
|
222
|
+
| `src/index.js` | Plugin descriptor for `astro.config.mjs`. Safe to load in Node. |
|
|
223
|
+
| `src/plugin.js` | Runtime plugin: hooks and the cleanup job. |
|
|
224
|
+
| `src/variants.js` | Resize, manifest, cleanup and `srcset` logic, with tests. |
|
|
225
|
+
| `src/runtime.js` | Worker bindings shared by the components. |
|
|
226
|
+
| `src/astro/` | `Image`, the Portable Text `ImageBlock`, and `blockComponents`. |
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emdash-plugin-r2-variants",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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.1",
|
|
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.1",
|
|
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
|
+
}
|
package/src/variants.js
ADDED
|
@@ -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
|
+
}
|