@thebes/cadmus-cloudflare-images 1.0.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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/index.cjs +59 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +30 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BowenLabs
|
|
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
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @thebes/cadmus-cloudflare-images
|
|
2
|
+
|
|
3
|
+
A [Cadmus](https://github.com/bowenlabs/project-thebes) **adapter** — an
|
|
4
|
+
alternate `ImageService` (from `@thebes/cadmus/storage`) backed by
|
|
5
|
+
[Cloudflare Image Resizing](https://developers.cloudflare.com/images/transform-images/).
|
|
6
|
+
|
|
7
|
+
This is the *adapter* extension axis: a swappable implementation of an
|
|
8
|
+
interface Cadmus already defines. Uploads still land in R2 and the database
|
|
9
|
+
still stores the **original** R2 URL — only `render()` changes, returning
|
|
10
|
+
responsive `/cdn-cgi/image/...` transform URLs instead of a pass-through `src`.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm add @thebes/cadmus-cloudflare-images
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Swap it in
|
|
17
|
+
|
|
18
|
+
Because every call site resolves its image service through one selection point
|
|
19
|
+
(e.g. `app/core/lib/image-service.ts`), switching is a one-line change — no
|
|
20
|
+
component, renderer, or stored data changes:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { createCloudflareImageService } from "@thebes/cadmus-cloudflare-images";
|
|
24
|
+
|
|
25
|
+
export function createImageService(bucket: R2Bucket, mediaUrl: string) {
|
|
26
|
+
return createCloudflareImageService({ bucket, mediaUrl });
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
createCloudflareImageService({
|
|
34
|
+
bucket, // R2Bucket — where originals are uploaded
|
|
35
|
+
mediaUrl, // public base URL for originals (R2 custom domain)
|
|
36
|
+
deliveryUrl, // origin that serves /cdn-cgi/image (default: mediaUrl)
|
|
37
|
+
widths, // srcset widths (default: 320…1920)
|
|
38
|
+
sizes, // sizes attribute (default: "100vw")
|
|
39
|
+
quality, // 1–100 (default: 80)
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`render({ url, width?, height?, alt })` returns `{ src, srcset?, sizes? }`. With
|
|
44
|
+
no explicit `width`/`height` it emits a full responsive `srcset`; a fixed
|
|
45
|
+
`width`/`height` pins a single rendition (no `srcset`). `upload(file)` validates
|
|
46
|
+
via `validateImageFile`, stores the original in R2, and returns its original
|
|
47
|
+
URL.
|
|
48
|
+
|
|
49
|
+
> Requires a Cloudflare zone with Image Resizing enabled on the delivery
|
|
50
|
+
> origin. Available on paid plans — see Cloudflare's docs.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT © BowenLabs
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _thebes_cadmus_storage = require("@thebes/cadmus/storage");
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
const DEFAULT_WIDTHS = [
|
|
5
|
+
320,
|
|
6
|
+
640,
|
|
7
|
+
960,
|
|
8
|
+
1280,
|
|
9
|
+
1920
|
|
10
|
+
];
|
|
11
|
+
function safeExtension(filename) {
|
|
12
|
+
const match = /\.([a-zA-Z0-9]{1,5})$/.exec(filename);
|
|
13
|
+
return match ? `.${match[1].toLowerCase()}` : "";
|
|
14
|
+
}
|
|
15
|
+
/** Builds a Cloudflare Image Resizing URL:
|
|
16
|
+
* `${deliveryUrl}/cdn-cgi/image/<options>/<source>`. */
|
|
17
|
+
function transformUrl(deliveryUrl, source, opts) {
|
|
18
|
+
const params = [`format=auto`, `quality=${opts.quality}`];
|
|
19
|
+
if (opts.width) params.push(`width=${opts.width}`);
|
|
20
|
+
if (opts.height) params.push(`height=${opts.height}`);
|
|
21
|
+
return `${deliveryUrl}/cdn-cgi/image/${params.join(",")}/${source}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Creates an `ImageService` that uploads to R2 and renders responsive
|
|
25
|
+
* Cloudflare Image Resizing URLs. Drop-in replacement for the default R2
|
|
26
|
+
* pass-through service — the database still stores the original R2 URL.
|
|
27
|
+
*/
|
|
28
|
+
function createCloudflareImageService(options) {
|
|
29
|
+
const { bucket, mediaUrl, deliveryUrl = mediaUrl, widths = DEFAULT_WIDTHS, sizes = "100vw", quality = 80 } = options;
|
|
30
|
+
return {
|
|
31
|
+
async upload(file) {
|
|
32
|
+
(0, _thebes_cadmus_storage.validateImageFile)(file);
|
|
33
|
+
const key = `${crypto.randomUUID()}${safeExtension(file.name)}`;
|
|
34
|
+
await bucket.put(key, file);
|
|
35
|
+
return { url: `${mediaUrl}/${key}` };
|
|
36
|
+
},
|
|
37
|
+
render({ url, width, height }) {
|
|
38
|
+
const largest = widths[widths.length - 1];
|
|
39
|
+
const src = transformUrl(deliveryUrl, url, {
|
|
40
|
+
width: width ?? largest,
|
|
41
|
+
height,
|
|
42
|
+
quality
|
|
43
|
+
});
|
|
44
|
+
if (width || height) return { src };
|
|
45
|
+
return {
|
|
46
|
+
src,
|
|
47
|
+
srcset: widths.map((w) => `${transformUrl(deliveryUrl, url, {
|
|
48
|
+
width: w,
|
|
49
|
+
quality
|
|
50
|
+
})} ${w}w`).join(", "),
|
|
51
|
+
sizes
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
exports.createCloudflareImageService = createCloudflareImageService;
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// MIT licensed. See LICENSE in the repo root.\n//\n// @thebes/cadmus-cloudflare-images\n//\n// A Cadmus *adapter* (the swappable-implementation axis): an alternate\n// `ImageService` (from @thebes/cadmus/storage) backed by Cloudflare\n// Image Resizing. Uploads still land in R2 and the database still stores\n// the original R2 URL — only `render()` differs, returning responsive\n// `/cdn-cgi/image/...` transform URLs instead of a pass-through `src`.\n//\n// Swap it in without touching any component or block data:\n//\n// import { createCloudflareImageService } from \"@thebes/cadmus-cloudflare-images\";\n// const images = createCloudflareImageService({ bucket: env.R2, mediaUrl: env.MEDIA_URL });\n\nimport type { ImageService, RenderedImage } from \"@thebes/cadmus/storage\";\nimport { validateImageFile } from \"@thebes/cadmus/storage\";\n\nexport interface CloudflareImagesOptions {\n /** R2 bucket originals are uploaded to. */\n bucket: R2Bucket;\n /** Public base URL for original objects (an R2 custom domain), no\n * trailing slash. Stored in the DB and used as the transform source. */\n mediaUrl: string;\n /**\n * Origin that serves `/cdn-cgi/image/...` (a zone with Image Resizing\n * enabled). Defaults to `mediaUrl`. No trailing slash.\n */\n deliveryUrl?: string;\n /** Widths emitted in `srcset`. Defaults to a sensible responsive set. */\n widths?: number[];\n /** `sizes` attribute value. Defaults to `100vw`. */\n sizes?: string;\n /** Output quality (1–100). Defaults to 80. */\n quality?: number;\n}\n\nconst DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920];\n\nfunction safeExtension(filename: string): string {\n const match = /\\.([a-zA-Z0-9]{1,5})$/.exec(filename);\n return match ? `.${match[1].toLowerCase()}` : \"\";\n}\n\n/** Builds a Cloudflare Image Resizing URL:\n * `${deliveryUrl}/cdn-cgi/image/<options>/<source>`. */\nfunction transformUrl(\n deliveryUrl: string,\n source: string,\n opts: { width?: number; height?: number; quality: number },\n): string {\n const params = [`format=auto`, `quality=${opts.quality}`];\n if (opts.width) params.push(`width=${opts.width}`);\n if (opts.height) params.push(`height=${opts.height}`);\n return `${deliveryUrl}/cdn-cgi/image/${params.join(\",\")}/${source}`;\n}\n\n/**\n * Creates an `ImageService` that uploads to R2 and renders responsive\n * Cloudflare Image Resizing URLs. Drop-in replacement for the default R2\n * pass-through service — the database still stores the original R2 URL.\n */\nexport function createCloudflareImageService(\n options: CloudflareImagesOptions,\n): ImageService {\n const {\n bucket,\n mediaUrl,\n deliveryUrl = mediaUrl,\n widths = DEFAULT_WIDTHS,\n sizes = \"100vw\",\n quality = 80,\n } = options;\n\n return {\n async upload(file) {\n // Defense-in-depth — callers should validate first, but upload() is\n // reachable directly too.\n validateImageFile(file);\n const key = `${crypto.randomUUID()}${safeExtension(file.name)}`;\n await bucket.put(key, file);\n // Store the ORIGINAL URL, never a transform URL (see CLAUDE.md\n // \"Image service interface\").\n return { url: `${mediaUrl}/${key}` };\n },\n\n render({ url, width, height }): RenderedImage {\n const largest = widths[widths.length - 1];\n const src = transformUrl(deliveryUrl, url, {\n width: width ?? largest,\n height,\n quality,\n });\n // A fixed width/height pins the rendition, so a srcset would be\n // misleading — emit just the single transformed src.\n if (width || height) {\n return { src };\n }\n const srcset = widths\n .map(\n (w) =>\n `${transformUrl(deliveryUrl, url, { width: w, quality })} ${w}w`,\n )\n .join(\", \");\n return { src, srcset, sizes };\n },\n };\n}\n"],"mappings":";;;AAsCA,MAAM,iBAAiB;CAAC;CAAK;CAAK;CAAK;CAAM;AAAI;AAEjD,SAAS,cAAc,UAA0B;CAC/C,MAAM,QAAQ,wBAAwB,KAAK,QAAQ;CACnD,OAAO,QAAQ,IAAI,MAAM,EAAE,CAAC,YAAY,MAAM;AAChD;;;AAIA,SAAS,aACP,aACA,QACA,MACQ;CACR,MAAM,SAAS,CAAC,eAAe,WAAW,KAAK,SAAS;CACxD,IAAI,KAAK,OAAO,OAAO,KAAK,SAAS,KAAK,OAAO;CACjD,IAAI,KAAK,QAAQ,OAAO,KAAK,UAAU,KAAK,QAAQ;CACpD,OAAO,GAAG,YAAY,iBAAiB,OAAO,KAAK,GAAG,EAAE,GAAG;AAC7D;;;;;;AAOA,SAAgB,6BACd,SACc;CACd,MAAM,EACJ,QACA,UACA,cAAc,UACd,SAAS,gBACT,QAAQ,SACR,UAAU,OACR;CAEJ,OAAO;EACL,MAAM,OAAO,MAAM;GAGjB,CAAA,GAAA,uBAAA,kBAAA,CAAkB,IAAI;GACtB,MAAM,MAAM,GAAG,OAAO,WAAW,IAAI,cAAc,KAAK,IAAI;GAC5D,MAAM,OAAO,IAAI,KAAK,IAAI;GAG1B,OAAO,EAAE,KAAK,GAAG,SAAS,GAAG,MAAM;EACrC;EAEA,OAAO,EAAE,KAAK,OAAO,UAAyB;GAC5C,MAAM,UAAU,OAAO,OAAO,SAAS;GACvC,MAAM,MAAM,aAAa,aAAa,KAAK;IACzC,OAAO,SAAS;IAChB;IACA;GACF,CAAC;GAGD,IAAI,SAAS,QACX,OAAO,EAAE,IAAI;GAQf,OAAO;IAAE;IAAK,QANC,OACZ,KACE,MACC,GAAG,aAAa,aAAa,KAAK;KAAE,OAAO;KAAG;IAAQ,CAAC,EAAE,GAAG,EAAE,EAClE,CAAC,CACA,KAAK,IACW;IAAG;GAAM;EAC9B;CACF;AACF"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ImageService } from "@thebes/cadmus/storage";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
interface CloudflareImagesOptions {
|
|
5
|
+
/** R2 bucket originals are uploaded to. */
|
|
6
|
+
bucket: R2Bucket;
|
|
7
|
+
/** Public base URL for original objects (an R2 custom domain), no
|
|
8
|
+
* trailing slash. Stored in the DB and used as the transform source. */
|
|
9
|
+
mediaUrl: string;
|
|
10
|
+
/**
|
|
11
|
+
* Origin that serves `/cdn-cgi/image/...` (a zone with Image Resizing
|
|
12
|
+
* enabled). Defaults to `mediaUrl`. No trailing slash.
|
|
13
|
+
*/
|
|
14
|
+
deliveryUrl?: string;
|
|
15
|
+
/** Widths emitted in `srcset`. Defaults to a sensible responsive set. */
|
|
16
|
+
widths?: number[];
|
|
17
|
+
/** `sizes` attribute value. Defaults to `100vw`. */
|
|
18
|
+
sizes?: string;
|
|
19
|
+
/** Output quality (1–100). Defaults to 80. */
|
|
20
|
+
quality?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Creates an `ImageService` that uploads to R2 and renders responsive
|
|
24
|
+
* Cloudflare Image Resizing URLs. Drop-in replacement for the default R2
|
|
25
|
+
* pass-through service — the database still stores the original R2 URL.
|
|
26
|
+
*/
|
|
27
|
+
declare function createCloudflareImageService(options: CloudflareImagesOptions): ImageService;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { CloudflareImagesOptions, createCloudflareImageService };
|
|
30
|
+
//# sourceMappingURL=index.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/index.ts"],"mappings":";;;UAmBiB,uBAAA;;EAEf,MAAA,EAAQ,QAAQ;EAFsB;;EAKtC,QAAA;EAHA;;;;EAQA,WAAA;EAIA;EAFA,MAAA;EAIO;EAFP,KAAA;EA8Bc;EA5Bd,OAAA;AAAA;;;;;;iBA4Bc,4BAAA,CACd,OAAA,EAAS,uBAAA,GACR,YAAY"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ImageService } from "@thebes/cadmus/storage";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
interface CloudflareImagesOptions {
|
|
5
|
+
/** R2 bucket originals are uploaded to. */
|
|
6
|
+
bucket: R2Bucket;
|
|
7
|
+
/** Public base URL for original objects (an R2 custom domain), no
|
|
8
|
+
* trailing slash. Stored in the DB and used as the transform source. */
|
|
9
|
+
mediaUrl: string;
|
|
10
|
+
/**
|
|
11
|
+
* Origin that serves `/cdn-cgi/image/...` (a zone with Image Resizing
|
|
12
|
+
* enabled). Defaults to `mediaUrl`. No trailing slash.
|
|
13
|
+
*/
|
|
14
|
+
deliveryUrl?: string;
|
|
15
|
+
/** Widths emitted in `srcset`. Defaults to a sensible responsive set. */
|
|
16
|
+
widths?: number[];
|
|
17
|
+
/** `sizes` attribute value. Defaults to `100vw`. */
|
|
18
|
+
sizes?: string;
|
|
19
|
+
/** Output quality (1–100). Defaults to 80. */
|
|
20
|
+
quality?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Creates an `ImageService` that uploads to R2 and renders responsive
|
|
24
|
+
* Cloudflare Image Resizing URLs. Drop-in replacement for the default R2
|
|
25
|
+
* pass-through service — the database still stores the original R2 URL.
|
|
26
|
+
*/
|
|
27
|
+
declare function createCloudflareImageService(options: CloudflareImagesOptions): ImageService;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { CloudflareImagesOptions, createCloudflareImageService };
|
|
30
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;UAmBiB,uBAAA;;EAEf,MAAA,EAAQ,QAAQ;EAFsB;;EAKtC,QAAA;EAHA;;;;EAQA,WAAA;EAIA;EAFA,MAAA;EAIO;EAFP,KAAA;EA8Bc;EA5Bd,OAAA;AAAA;;;;;;iBA4Bc,4BAAA,CACd,OAAA,EAAS,uBAAA,GACR,YAAY"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { validateImageFile } from "@thebes/cadmus/storage";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
const DEFAULT_WIDTHS = [
|
|
4
|
+
320,
|
|
5
|
+
640,
|
|
6
|
+
960,
|
|
7
|
+
1280,
|
|
8
|
+
1920
|
|
9
|
+
];
|
|
10
|
+
function safeExtension(filename) {
|
|
11
|
+
const match = /\.([a-zA-Z0-9]{1,5})$/.exec(filename);
|
|
12
|
+
return match ? `.${match[1].toLowerCase()}` : "";
|
|
13
|
+
}
|
|
14
|
+
/** Builds a Cloudflare Image Resizing URL:
|
|
15
|
+
* `${deliveryUrl}/cdn-cgi/image/<options>/<source>`. */
|
|
16
|
+
function transformUrl(deliveryUrl, source, opts) {
|
|
17
|
+
const params = [`format=auto`, `quality=${opts.quality}`];
|
|
18
|
+
if (opts.width) params.push(`width=${opts.width}`);
|
|
19
|
+
if (opts.height) params.push(`height=${opts.height}`);
|
|
20
|
+
return `${deliveryUrl}/cdn-cgi/image/${params.join(",")}/${source}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Creates an `ImageService` that uploads to R2 and renders responsive
|
|
24
|
+
* Cloudflare Image Resizing URLs. Drop-in replacement for the default R2
|
|
25
|
+
* pass-through service — the database still stores the original R2 URL.
|
|
26
|
+
*/
|
|
27
|
+
function createCloudflareImageService(options) {
|
|
28
|
+
const { bucket, mediaUrl, deliveryUrl = mediaUrl, widths = DEFAULT_WIDTHS, sizes = "100vw", quality = 80 } = options;
|
|
29
|
+
return {
|
|
30
|
+
async upload(file) {
|
|
31
|
+
validateImageFile(file);
|
|
32
|
+
const key = `${crypto.randomUUID()}${safeExtension(file.name)}`;
|
|
33
|
+
await bucket.put(key, file);
|
|
34
|
+
return { url: `${mediaUrl}/${key}` };
|
|
35
|
+
},
|
|
36
|
+
render({ url, width, height }) {
|
|
37
|
+
const largest = widths[widths.length - 1];
|
|
38
|
+
const src = transformUrl(deliveryUrl, url, {
|
|
39
|
+
width: width ?? largest,
|
|
40
|
+
height,
|
|
41
|
+
quality
|
|
42
|
+
});
|
|
43
|
+
if (width || height) return { src };
|
|
44
|
+
return {
|
|
45
|
+
src,
|
|
46
|
+
srcset: widths.map((w) => `${transformUrl(deliveryUrl, url, {
|
|
47
|
+
width: w,
|
|
48
|
+
quality
|
|
49
|
+
})} ${w}w`).join(", "),
|
|
50
|
+
sizes
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
export { createCloudflareImageService };
|
|
57
|
+
|
|
58
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// MIT licensed. See LICENSE in the repo root.\n//\n// @thebes/cadmus-cloudflare-images\n//\n// A Cadmus *adapter* (the swappable-implementation axis): an alternate\n// `ImageService` (from @thebes/cadmus/storage) backed by Cloudflare\n// Image Resizing. Uploads still land in R2 and the database still stores\n// the original R2 URL — only `render()` differs, returning responsive\n// `/cdn-cgi/image/...` transform URLs instead of a pass-through `src`.\n//\n// Swap it in without touching any component or block data:\n//\n// import { createCloudflareImageService } from \"@thebes/cadmus-cloudflare-images\";\n// const images = createCloudflareImageService({ bucket: env.R2, mediaUrl: env.MEDIA_URL });\n\nimport type { ImageService, RenderedImage } from \"@thebes/cadmus/storage\";\nimport { validateImageFile } from \"@thebes/cadmus/storage\";\n\nexport interface CloudflareImagesOptions {\n /** R2 bucket originals are uploaded to. */\n bucket: R2Bucket;\n /** Public base URL for original objects (an R2 custom domain), no\n * trailing slash. Stored in the DB and used as the transform source. */\n mediaUrl: string;\n /**\n * Origin that serves `/cdn-cgi/image/...` (a zone with Image Resizing\n * enabled). Defaults to `mediaUrl`. No trailing slash.\n */\n deliveryUrl?: string;\n /** Widths emitted in `srcset`. Defaults to a sensible responsive set. */\n widths?: number[];\n /** `sizes` attribute value. Defaults to `100vw`. */\n sizes?: string;\n /** Output quality (1–100). Defaults to 80. */\n quality?: number;\n}\n\nconst DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920];\n\nfunction safeExtension(filename: string): string {\n const match = /\\.([a-zA-Z0-9]{1,5})$/.exec(filename);\n return match ? `.${match[1].toLowerCase()}` : \"\";\n}\n\n/** Builds a Cloudflare Image Resizing URL:\n * `${deliveryUrl}/cdn-cgi/image/<options>/<source>`. */\nfunction transformUrl(\n deliveryUrl: string,\n source: string,\n opts: { width?: number; height?: number; quality: number },\n): string {\n const params = [`format=auto`, `quality=${opts.quality}`];\n if (opts.width) params.push(`width=${opts.width}`);\n if (opts.height) params.push(`height=${opts.height}`);\n return `${deliveryUrl}/cdn-cgi/image/${params.join(\",\")}/${source}`;\n}\n\n/**\n * Creates an `ImageService` that uploads to R2 and renders responsive\n * Cloudflare Image Resizing URLs. Drop-in replacement for the default R2\n * pass-through service — the database still stores the original R2 URL.\n */\nexport function createCloudflareImageService(\n options: CloudflareImagesOptions,\n): ImageService {\n const {\n bucket,\n mediaUrl,\n deliveryUrl = mediaUrl,\n widths = DEFAULT_WIDTHS,\n sizes = \"100vw\",\n quality = 80,\n } = options;\n\n return {\n async upload(file) {\n // Defense-in-depth — callers should validate first, but upload() is\n // reachable directly too.\n validateImageFile(file);\n const key = `${crypto.randomUUID()}${safeExtension(file.name)}`;\n await bucket.put(key, file);\n // Store the ORIGINAL URL, never a transform URL (see CLAUDE.md\n // \"Image service interface\").\n return { url: `${mediaUrl}/${key}` };\n },\n\n render({ url, width, height }): RenderedImage {\n const largest = widths[widths.length - 1];\n const src = transformUrl(deliveryUrl, url, {\n width: width ?? largest,\n height,\n quality,\n });\n // A fixed width/height pins the rendition, so a srcset would be\n // misleading — emit just the single transformed src.\n if (width || height) {\n return { src };\n }\n const srcset = widths\n .map(\n (w) =>\n `${transformUrl(deliveryUrl, url, { width: w, quality })} ${w}w`,\n )\n .join(\", \");\n return { src, srcset, sizes };\n },\n };\n}\n"],"mappings":";;AAsCA,MAAM,iBAAiB;CAAC;CAAK;CAAK;CAAK;CAAM;AAAI;AAEjD,SAAS,cAAc,UAA0B;CAC/C,MAAM,QAAQ,wBAAwB,KAAK,QAAQ;CACnD,OAAO,QAAQ,IAAI,MAAM,EAAE,CAAC,YAAY,MAAM;AAChD;;;AAIA,SAAS,aACP,aACA,QACA,MACQ;CACR,MAAM,SAAS,CAAC,eAAe,WAAW,KAAK,SAAS;CACxD,IAAI,KAAK,OAAO,OAAO,KAAK,SAAS,KAAK,OAAO;CACjD,IAAI,KAAK,QAAQ,OAAO,KAAK,UAAU,KAAK,QAAQ;CACpD,OAAO,GAAG,YAAY,iBAAiB,OAAO,KAAK,GAAG,EAAE,GAAG;AAC7D;;;;;;AAOA,SAAgB,6BACd,SACc;CACd,MAAM,EACJ,QACA,UACA,cAAc,UACd,SAAS,gBACT,QAAQ,SACR,UAAU,OACR;CAEJ,OAAO;EACL,MAAM,OAAO,MAAM;GAGjB,kBAAkB,IAAI;GACtB,MAAM,MAAM,GAAG,OAAO,WAAW,IAAI,cAAc,KAAK,IAAI;GAC5D,MAAM,OAAO,IAAI,KAAK,IAAI;GAG1B,OAAO,EAAE,KAAK,GAAG,SAAS,GAAG,MAAM;EACrC;EAEA,OAAO,EAAE,KAAK,OAAO,UAAyB;GAC5C,MAAM,UAAU,OAAO,OAAO,SAAS;GACvC,MAAM,MAAM,aAAa,aAAa,KAAK;IACzC,OAAO,SAAS;IAChB;IACA;GACF,CAAC;GAGD,IAAI,SAAS,QACX,OAAO,EAAE,IAAI;GAQf,OAAO;IAAE;IAAK,QANC,OACZ,KACE,MACC,GAAG,aAAa,aAAa,KAAK;KAAE,OAAO;KAAG;IAAQ,CAAC,EAAE,GAAG,EAAE,EAClE,CAAC,CACA,KAAK,IACW;IAAG;GAAM;EAC9B;CACF;AACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thebes/cadmus-cloudflare-images",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cloudflare Image Resizing adapter for @thebes/cadmus/storage's ImageService",
|
|
5
|
+
"author": "BowenLabs <hello@bowenlabs.io>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/bowenlabs/project-thebes",
|
|
10
|
+
"directory": "packages/cadmus-cloudflare-images"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/bowenlabs/project-thebes/tree/main/packages/cadmus-cloudflare-images#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/bowenlabs/project-thebes/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cadmus",
|
|
18
|
+
"cloudflare",
|
|
19
|
+
"cloudflare-images",
|
|
20
|
+
"image-resizing",
|
|
21
|
+
"r2",
|
|
22
|
+
"adapter"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@thebes/cadmus": "^0.2.1"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@cloudflare/workers-types": "latest",
|
|
36
|
+
"typescript": "latest",
|
|
37
|
+
"vite-plus": "latest",
|
|
38
|
+
"vitest": "latest",
|
|
39
|
+
"@thebes/cadmus": "^0.2.1"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "vp pack",
|
|
48
|
+
"dev": "vp pack --watch",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest"
|
|
51
|
+
}
|
|
52
|
+
}
|