@arraypress/media-url 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/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@arraypress/media-url",
3
+ "version": "1.0.0",
4
+ "description": "Build media URLs with optional Cloudflare Image Resizing transforms.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "src/index.d.ts",
8
+ "exports": { ".": { "import": "./src/index.js", "types": "./src/index.d.ts" } },
9
+ "files": ["src"],
10
+ "scripts": { "test": "node --test tests/media-url.test.js" },
11
+ "keywords": ["media", "url", "cloudflare", "image-resizing", "cdn", "r2"],
12
+ "author": "David Sherlock",
13
+ "license": "MIT",
14
+ "repository": { "type": "git", "url": "https://github.com/arraypress/media-url" }
15
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ export interface TransformOptions {
2
+ /** Target width in pixels. */
3
+ width?: number;
4
+ /** Target height in pixels. */
5
+ height?: number;
6
+ /** Quality 1-100 (default 80). */
7
+ quality?: number;
8
+ /** Fit mode: cover, contain, scale-down, crop, pad (default cover). */
9
+ fit?: string;
10
+ /** Output format: auto, webp, avif, json (default auto). */
11
+ format?: string;
12
+ }
13
+
14
+ export interface MediaUrlConfig {
15
+ /** Public base URL for direct access (e.g. "https://media.example.com"). */
16
+ baseUrl?: string;
17
+ /** Enable Cloudflare Image Resizing (default false). */
18
+ resizing?: boolean;
19
+ /** Proxy fallback path prefix (default "/api/media"). */
20
+ proxyPath?: string;
21
+ }
22
+
23
+ export interface MediaUrlOptions {
24
+ /** Media ID (used for proxy fallback). */
25
+ id: string | number;
26
+ /** Storage object key (e.g. "media/abc.jpg"). */
27
+ fileKey: string;
28
+ /** Image transform options. */
29
+ transform?: TransformOptions;
30
+ }
31
+
32
+ export interface ImageRecord {
33
+ id: string | number;
34
+ file_key: string;
35
+ alt_text?: string;
36
+ }
37
+
38
+ export interface ImageResult {
39
+ id: string | number;
40
+ url: string;
41
+ thumbnail: string;
42
+ alt: string;
43
+ }
44
+
45
+ export interface AudioRecord {
46
+ media_id?: string | number;
47
+ external_url?: string;
48
+ }
49
+
50
+ export interface ThumbnailSizes {
51
+ thumbnail?: TransformOptions;
52
+ }
53
+
54
+ /** Build a public URL for a media item with optional Cloudflare Image Resizing. */
55
+ export function mediaUrl(options: MediaUrlOptions, config?: MediaUrlConfig): string;
56
+
57
+ /** Build URLs for a list of images with multiple size variants. */
58
+ export function buildImageUrls(images: ImageRecord[], config?: MediaUrlConfig, sizes?: ThumbnailSizes): ImageResult[];
59
+
60
+ /** Resolve a URL for an audio file. */
61
+ export function buildAudioUrl(audio: AudioRecord, config?: MediaUrlConfig, mediaLookup?: Record<string | number, string>): string;
package/src/index.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @arraypress/media-url
3
+ *
4
+ * Build media URLs with optional Cloudflare Image Resizing transforms.
5
+ *
6
+ * Three modes based on configuration:
7
+ * 1. Proxy fallback: /api/media/:id
8
+ * 2. Direct URL: https://media.example.com/media/abc.jpg
9
+ * 3. With resizing: /cdn-cgi/image/w=400,q=80,f=auto/https://media.example.com/media/abc.jpg
10
+ *
11
+ * Zero dependencies. Works in any JS runtime.
12
+ *
13
+ * @module @arraypress/media-url
14
+ */
15
+
16
+ /**
17
+ * Build a public URL for a media item.
18
+ *
19
+ * When no baseUrl is provided, falls back to the proxyPath.
20
+ * When baseUrl is set but resizing is off, returns a direct URL.
21
+ * When both baseUrl and resizing are enabled, wraps the direct URL
22
+ * with Cloudflare Image Resizing.
23
+ *
24
+ * @param {Object} options
25
+ * @param {string|number} options.id - Media ID (used for proxy fallback).
26
+ * @param {string} options.fileKey - Storage object key (e.g. "media/abc.jpg").
27
+ * @param {Object} [options.transform] - Image transform options.
28
+ * @param {number} [options.transform.width] - Target width in pixels.
29
+ * @param {number} [options.transform.height] - Target height in pixels.
30
+ * @param {number} [options.transform.quality] - Quality 1-100 (default 80).
31
+ * @param {string} [options.transform.fit] - Fit mode: cover, contain, scale-down, crop, pad (default cover).
32
+ * @param {string} [options.transform.format] - Output format: auto, webp, avif, json (default auto).
33
+ * @param {Object} [config] - URL configuration.
34
+ * @param {string} [config.baseUrl] - Public base URL for direct access (e.g. "https://media.example.com").
35
+ * @param {boolean} [config.resizing] - Enable Cloudflare Image Resizing (default false).
36
+ * @param {string} [config.proxyPath] - Proxy fallback path prefix (default "/api/media").
37
+ * @returns {string} The resolved URL.
38
+ *
39
+ * @example
40
+ * // Proxy fallback (no baseUrl)
41
+ * mediaUrl({ id: 42, fileKey: 'media/photo.jpg' });
42
+ * // → '/api/media/42'
43
+ *
44
+ * @example
45
+ * // Direct R2 URL
46
+ * mediaUrl(
47
+ * { id: 42, fileKey: 'media/photo.jpg' },
48
+ * { baseUrl: 'https://media.example.com' }
49
+ * );
50
+ * // → 'https://media.example.com/media/photo.jpg'
51
+ *
52
+ * @example
53
+ * // With Cloudflare Image Resizing
54
+ * mediaUrl(
55
+ * { id: 42, fileKey: 'media/photo.jpg', transform: { width: 400 } },
56
+ * { baseUrl: 'https://media.example.com', resizing: true }
57
+ * );
58
+ * // → '/cdn-cgi/image/w=400,q=80,f=auto,fit=cover/https://media.example.com/media/photo.jpg'
59
+ */
60
+ export function mediaUrl({ id, fileKey, transform }, config = {}) {
61
+ const baseUrl = config.baseUrl || '';
62
+ const resizing = config.resizing === true;
63
+ const proxyPath = config.proxyPath || '/api/media';
64
+
65
+ // No base URL — use proxy fallback
66
+ if (!baseUrl) {
67
+ return `${proxyPath}/${id}`;
68
+ }
69
+
70
+ // Build the direct URL
71
+ const directUrl = `${baseUrl.replace(/\/$/, '')}/${fileKey}`;
72
+
73
+ // No resizing or no transform — return direct URL
74
+ if (!resizing || !transform) {
75
+ return directUrl;
76
+ }
77
+
78
+ // Build Cloudflare Image Resizing URL
79
+ const params = [];
80
+ if (transform.width) params.push(`w=${transform.width}`);
81
+ if (transform.height) params.push(`h=${transform.height}`);
82
+ params.push(`q=${transform.quality || 80}`);
83
+ params.push(`f=${transform.format || 'auto'}`);
84
+ params.push(`fit=${transform.fit || 'cover'}`);
85
+
86
+ return `/cdn-cgi/image/${params.join(',')}/${directUrl}`;
87
+ }
88
+
89
+ /**
90
+ * Build URLs for a list of images with multiple size variants.
91
+ *
92
+ * Takes raw image records and returns objects with full URL,
93
+ * thumbnail URL, and alt text.
94
+ *
95
+ * @param {Array<Object>} images - Image records with id, file_key, and optional alt_text.
96
+ * @param {Object} [config] - URL configuration (same as mediaUrl config).
97
+ * @param {Object} [sizes] - Size configuration.
98
+ * @param {Object} [sizes.thumbnail] - Thumbnail transform (default { width: 400, quality: 80 }).
99
+ * @returns {Array<Object>} Image objects with id, url, thumbnail, and alt.
100
+ *
101
+ * @example
102
+ * const images = buildImageUrls(
103
+ * [{ id: 1, file_key: 'media/photo.jpg', alt_text: 'A photo' }],
104
+ * { baseUrl: 'https://media.example.com', resizing: true }
105
+ * );
106
+ * // [{ id: 1, url: 'https://...', thumbnail: '/cdn-cgi/...', alt: 'A photo' }]
107
+ */
108
+ export function buildImageUrls(images, config = {}, sizes = {}) {
109
+ const thumbnailTransform = sizes.thumbnail || { width: 400, quality: 80 };
110
+
111
+ return images.map(img => {
112
+ const base = { id: img.id, fileKey: img.file_key };
113
+ return {
114
+ id: img.id,
115
+ url: mediaUrl(base, config),
116
+ thumbnail: mediaUrl({ ...base, transform: thumbnailTransform }, config),
117
+ alt: img.alt_text || '',
118
+ };
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Resolve a URL for an audio file.
124
+ *
125
+ * Audio files are never resized. If the item has no media_id,
126
+ * falls back to its external_url.
127
+ *
128
+ * @param {Object} audio - Audio record with media_id and/or external_url.
129
+ * @param {Object} [config] - URL configuration (same as mediaUrl config).
130
+ * @param {Object} [mediaLookup] - Map of media_id → file_key for resolving storage keys.
131
+ * @returns {string} The audio URL.
132
+ *
133
+ * @example
134
+ * // External URL (no media_id)
135
+ * buildAudioUrl({ external_url: 'https://cdn.example.com/song.mp3' });
136
+ * // → 'https://cdn.example.com/song.mp3'
137
+ *
138
+ * @example
139
+ * // R2-hosted audio
140
+ * buildAudioUrl(
141
+ * { media_id: 5 },
142
+ * { baseUrl: 'https://media.example.com' },
143
+ * { 5: 'media/song.mp3' }
144
+ * );
145
+ * // → 'https://media.example.com/media/song.mp3'
146
+ */
147
+ export function buildAudioUrl(audio, config = {}, mediaLookup = {}) {
148
+ if (!audio.media_id) return audio.external_url || '';
149
+
150
+ const fileKey = mediaLookup[audio.media_id];
151
+ if (!fileKey) {
152
+ const proxyPath = config.proxyPath || '/api/media';
153
+ return `${proxyPath}/${audio.media_id}`;
154
+ }
155
+
156
+ return mediaUrl({ id: audio.media_id, fileKey }, config);
157
+ }