@evfrenkel/decap-cms-core 3.13.0-image-conversions.2 → 3.13.0-image-conversions.6

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.
@@ -49,8 +49,8 @@ function bootstrap(opts = {}) {
49
49
  /**
50
50
  * Log the version number.
51
51
  */
52
- if (typeof "3.13.0-image-conversions.1" === 'string') {
53
- console.log(`decap-cms-core ${"3.13.0-image-conversions.1"}`);
52
+ if (typeof "3.13.0-image-conversions.5" === 'string') {
53
+ console.log(`decap-cms-core ${"3.13.0-image-conversions.5"}`);
54
54
  }
55
55
 
56
56
  /**
@@ -42,8 +42,8 @@ function buildIssueTemplate({
42
42
  let version = '';
43
43
  if (typeof DECAP_CMS_VERSION === 'string') {
44
44
  version = `decap-cms@${DECAP_CMS_VERSION}`;
45
- } else if (typeof "3.12.2-image-conversions.1" === 'string') {
46
- version = `decap-cms-app@${"3.12.2-image-conversions.1"}`;
45
+ } else if (typeof "3.12.2-image-conversions.5" === 'string') {
46
+ version = `decap-cms-app@${"3.12.2-image-conversions.5"}`;
47
47
  }
48
48
  const template = getIssueTemplate({
49
49
  version,
@@ -98,11 +98,23 @@ function getImageBytes(image, format, quality = 75) {
98
98
  }
99
99
  return image.get_bytes();
100
100
  }
101
+ async function getFileBytes(file) {
102
+ if (typeof file.arrayBuffer === 'function') {
103
+ return new Uint8Array(await file.arrayBuffer());
104
+ }
105
+ return new Promise((resolve, reject) => {
106
+ const reader = new FileReader();
107
+ reader.onload = () => resolve(new Uint8Array(reader.result));
108
+ reader.onerror = () => reject(reader.error);
109
+ reader.readAsArrayBuffer(file);
110
+ });
111
+ }
101
112
  export async function transformImage(file, originalPath, config) {
102
113
  const photon = await import('@silvia-odwyer/photon');
103
114
  const initPhoton = photon.default;
104
115
  await initPhoton();
105
- const originalImage = photon.PhotonImage.new_from_blob(file);
116
+ const imageBytes = await getFileBytes(file);
117
+ const originalImage = photon.PhotonImage.new_from_byteslice(imageBytes);
106
118
  try {
107
119
  const transformedFiles = config.variants.map(variant => {
108
120
  const {
@@ -439,6 +439,14 @@ function getFileField(collectionFiles, slug) {
439
439
  const file = collectionFiles.find(f => f?.get('name') === slug);
440
440
  return file;
441
441
  }
442
+ function getMediaPublicPathSegment(mediaPath) {
443
+ const normalizedPath = trim(mediaPath, '/');
444
+ const transformationIndex = normalizedPath.indexOf('_transformations/');
445
+ if (transformationIndex >= 0) {
446
+ return normalizedPath.slice(transformationIndex);
447
+ }
448
+ return basename(mediaPath);
449
+ }
442
450
  function hasCustomFolder(folderKey, collection, slug, field) {
443
451
  if (!collection) {
444
452
  return false;
@@ -556,9 +564,9 @@ export function selectMediaFilePublicPath(config, collection, mediaPath, entryMa
556
564
  publicFolder = normalizeDoubleSlashes(evaluateFolder(name, config, collection, entryMap, field));
557
565
  }
558
566
  if (isAbsolutePath(publicFolder)) {
559
- return joinUrlPath(publicFolder, basename(mediaPath));
567
+ return joinUrlPath(publicFolder, getMediaPublicPathSegment(mediaPath));
560
568
  }
561
- return join(publicFolder, basename(mediaPath));
569
+ return join(publicFolder, getMediaPublicPathSegment(mediaPath));
562
570
  }
563
571
  export function selectEditingDraft(state) {
564
572
  const entry = state.get('entry');
@@ -4,6 +4,10 @@ import { dirname } from 'path';
4
4
  import { MEDIA_LIBRARY_OPEN, MEDIA_LIBRARY_CLOSE, MEDIA_LIBRARY_CREATE, MEDIA_INSERT, MEDIA_REMOVE_INSERTED, MEDIA_LOAD_REQUEST, MEDIA_LOAD_SUCCESS, MEDIA_LOAD_FAILURE, MEDIA_PERSIST_REQUEST, MEDIA_PERSIST_SUCCESS, MEDIA_PERSIST_FAILURE, MEDIA_DELETE_REQUEST, MEDIA_DELETE_SUCCESS, MEDIA_DELETE_FAILURE, MEDIA_DISPLAY_URL_REQUEST, MEDIA_DISPLAY_URL_SUCCESS, MEDIA_DISPLAY_URL_FAILURE } from '../actions/mediaLibrary';
5
5
  import { selectEditingDraft, selectMediaFolder } from './entries';
6
6
  import { selectIntegration } from './';
7
+ function isMediaFileInFolder(filePath, mediaFolder) {
8
+ const fileFolder = dirname(filePath);
9
+ return fileFolder === mediaFolder || fileFolder.startsWith(`${mediaFolder}/_transformations/`);
10
+ }
7
11
  const defaultState = {
8
12
  isVisible: false,
9
13
  showMediaButton: true,
@@ -231,7 +235,7 @@ export function selectMediaFiles(state, field) {
231
235
  const entry = entryDraft.get('entry');
232
236
  const collection = state.collections.get(entry?.get('collection'));
233
237
  const mediaFolder = selectMediaFolder(state.config, collection, entry, field);
234
- files = entryFiles.filter(f => dirname(f.path) === mediaFolder).map(file => ({
238
+ files = entryFiles.filter(f => isMediaFileInFolder(f.path, mediaFolder)).map(file => ({
235
239
  key: file.id,
236
240
  ...file
237
241
  }));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@evfrenkel/decap-cms-core",
3
3
  "description": "Decap CMS core application, see decap-cms package for the main distribution.",
4
- "version": "3.13.0-image-conversions.2",
4
+ "version": "3.13.0-image-conversions.6",
5
5
  "module": "dist/esm/index.js",
6
6
  "main": "dist/decap-cms-core.js",
7
7
  "files": [
@@ -4,9 +4,41 @@ import {
4
4
  getImageTransformationsConfig,
5
5
  shouldTransformImage,
6
6
  sortTransformationFilesForSelection,
7
+ transformImage,
7
8
  } from '../imageTransformations';
8
9
 
10
+ const mockPhotonImage = {
11
+ get_width: jest.fn(() => 100),
12
+ get_height: jest.fn(() => 50),
13
+ get_bytes_webp: jest.fn(() => new Uint8Array([1, 2, 3])),
14
+ get_bytes_jpeg: jest.fn(() => new Uint8Array([4, 5, 6])),
15
+ get_bytes: jest.fn(() => new Uint8Array([7, 8, 9])),
16
+ free: jest.fn(),
17
+ };
18
+
19
+ const mockNewFromByteslice = jest.fn(() => mockPhotonImage);
20
+ const mockNewFromBlob = jest.fn();
21
+
22
+ jest.mock(
23
+ '@silvia-odwyer/photon',
24
+ () => ({
25
+ __esModule: true,
26
+ default: jest.fn(() => Promise.resolve()),
27
+ PhotonImage: {
28
+ new_from_byteslice: mockNewFromByteslice,
29
+ new_from_blob: mockNewFromBlob,
30
+ },
31
+ resize: jest.fn(),
32
+ SamplingFilter: { Lanczos3: 'Lanczos3' },
33
+ }),
34
+ { virtual: true },
35
+ );
36
+
9
37
  describe('imageTransformations', () => {
38
+ beforeEach(() => {
39
+ jest.clearAllMocks();
40
+ });
41
+
10
42
  describe('getImageTransformationsConfig', () => {
11
43
  it('normalizes array shorthand and keeps the original by default', () => {
12
44
  expect(
@@ -105,4 +137,25 @@ describe('imageTransformations', () => {
105
137
  expect(sortTransformationFilesForSelection([original, small])).toEqual([small, original]);
106
138
  });
107
139
  });
140
+
141
+ describe('transformImage', () => {
142
+ it('creates the Photon image from file bytes instead of blob', async () => {
143
+ const file = new File([new Uint8Array([255, 216, 255])], 'image.jpg', {
144
+ type: 'image/jpeg',
145
+ });
146
+
147
+ const files = await transformImage(file, 'uploads/image.jpg', {
148
+ keepOriginal: false,
149
+ variants: [{ name: 'webp', format: 'webp', keep_original_size: true }],
150
+ });
151
+
152
+ expect(mockNewFromByteslice).toHaveBeenCalledTimes(1);
153
+ expect(mockNewFromByteslice.mock.calls[0][0]).toEqual(new Uint8Array([255, 216, 255]));
154
+ expect(mockNewFromBlob).not.toHaveBeenCalled();
155
+ expect(files).toHaveLength(1);
156
+ expect(files[0].file.name).toBe('image.webp');
157
+ expect(files[0].path).toBe('uploads/_transformations/webp/image.webp');
158
+ expect(mockPhotonImage.free).toHaveBeenCalledTimes(1);
159
+ });
160
+ });
108
161
  });
@@ -174,6 +174,19 @@ function getImageBytes(image: PhotonImage, format: string, quality = 75) {
174
174
  return image.get_bytes();
175
175
  }
176
176
 
177
+ async function getFileBytes(file: File) {
178
+ if (typeof file.arrayBuffer === 'function') {
179
+ return new Uint8Array(await file.arrayBuffer());
180
+ }
181
+
182
+ return new Promise<Uint8Array>((resolve, reject) => {
183
+ const reader = new FileReader();
184
+ reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
185
+ reader.onerror = () => reject(reader.error);
186
+ reader.readAsArrayBuffer(file);
187
+ });
188
+ }
189
+
177
190
  export async function transformImage(
178
191
  file: File,
179
192
  originalPath: string,
@@ -183,7 +196,8 @@ export async function transformImage(
183
196
  const initPhoton = (photon as unknown as { default: () => Promise<unknown> }).default;
184
197
  await initPhoton();
185
198
 
186
- const originalImage = photon.PhotonImage.new_from_blob(file);
199
+ const imageBytes = await getFileBytes(file);
200
+ const originalImage = photon.PhotonImage.new_from_byteslice(imageBytes);
187
201
 
188
202
  try {
189
203
  const transformedFiles = config.variants.map(variant => {
@@ -549,6 +549,18 @@ describe('entries', () => {
549
549
  ).toEqual('/static/media/hosting-and-deployment/deployment-with-nanobox/image.png');
550
550
  });
551
551
 
552
+ it('should preserve generated transformation paths', () => {
553
+ expect(
554
+ selectMediaFilePublicPath(
555
+ { public_folder: '/uploads' },
556
+ null,
557
+ 'public/uploads/_transformations/webp/kittens.webp',
558
+ undefined,
559
+ undefined,
560
+ ),
561
+ ).toBe('/uploads/_transformations/webp/kittens.webp');
562
+ });
563
+
552
564
  it('should handle file public_folder', () => {
553
565
  const entry = fromJS({
554
566
  path: 'src/posts/index.md',
@@ -108,6 +108,35 @@ describe('mediaLibrary', () => {
108
108
  expect(selectMediaFolder).toHaveBeenCalledWith(state.config, collection, entry, imageField);
109
109
  });
110
110
 
111
+ it('should select draft transformation media files from collection when editing a draft', () => {
112
+ const { selectEditingDraft, selectMediaFolder } = require('../../reducers/entries');
113
+
114
+ selectEditingDraft.mockReturnValue(true);
115
+ selectMediaFolder.mockReturnValue('static/images/posts');
116
+
117
+ const imageField = fromJS({ name: 'image' });
118
+ const collection = fromJS({ fields: [imageField] });
119
+ const entry = fromJS({
120
+ collection: 'posts',
121
+ mediaFiles: [
122
+ { id: 1, path: 'static/images/posts/_transformations/webp/logo.webp' },
123
+ { id: 2, path: 'static/images/other/_transformations/webp/image.webp' },
124
+ ],
125
+ data: {},
126
+ });
127
+ const state = {
128
+ config: {},
129
+ collections: fromJS({ posts: collection }),
130
+ entryDraft: fromJS({
131
+ entry,
132
+ }),
133
+ };
134
+
135
+ expect(selectMediaFiles(state, imageField)).toEqual([
136
+ { id: 1, key: 1, path: 'static/images/posts/_transformations/webp/logo.webp' },
137
+ ]);
138
+ });
139
+
111
140
  it('should select global media files when not editing a draft', () => {
112
141
  const { selectEditingDraft } = require('../../reducers/entries');
113
142
 
@@ -545,6 +545,17 @@ function getFileField(collectionFiles: CollectionFiles, slug: string | undefined
545
545
  return file;
546
546
  }
547
547
 
548
+ function getMediaPublicPathSegment(mediaPath: string) {
549
+ const normalizedPath = trim(mediaPath, '/');
550
+ const transformationIndex = normalizedPath.indexOf('_transformations/');
551
+
552
+ if (transformationIndex >= 0) {
553
+ return normalizedPath.slice(transformationIndex);
554
+ }
555
+
556
+ return basename(mediaPath);
557
+ }
558
+
548
559
  function hasCustomFolder(
549
560
  folderKey: 'media_folder' | 'public_folder',
550
561
  collection: Collection | null,
@@ -804,10 +815,10 @@ export function selectMediaFilePublicPath(
804
815
  }
805
816
 
806
817
  if (isAbsolutePath(publicFolder)) {
807
- return joinUrlPath(publicFolder, basename(mediaPath));
818
+ return joinUrlPath(publicFolder, getMediaPublicPathSegment(mediaPath));
808
819
  }
809
820
 
810
- return join(publicFolder, basename(mediaPath));
821
+ return join(publicFolder, getMediaPublicPathSegment(mediaPath));
811
822
  }
812
823
 
813
824
  export function selectEditingDraft(state: EntryDraft) {
@@ -34,6 +34,11 @@ import type {
34
34
  EntryField,
35
35
  } from '../types/redux';
36
36
 
37
+ function isMediaFileInFolder(filePath: string, mediaFolder: string) {
38
+ const fileFolder = dirname(filePath);
39
+ return fileFolder === mediaFolder || fileFolder.startsWith(`${mediaFolder}/_transformations/`);
40
+ }
41
+
37
42
  const defaultState: {
38
43
  isVisible: boolean;
39
44
  showMediaButton: boolean;
@@ -270,7 +275,7 @@ export function selectMediaFiles(state: State, field?: EntryField) {
270
275
  const collection = state.collections.get(entry?.get('collection'));
271
276
  const mediaFolder = selectMediaFolder(state.config, collection, entry, field);
272
277
  files = entryFiles
273
- .filter(f => dirname(f.path) === mediaFolder)
278
+ .filter(f => isMediaFileInFolder(f.path, mediaFolder))
274
279
  .map(file => ({ key: file.id, ...file }));
275
280
  } else {
276
281
  files = mediaLibrary.get('files') || [];