add local plugin

This commit is contained in:
Eli Kinsey
2024-09-12 15:33:50 -07:00
parent c1f32734b1
commit 29a9aba70c
27 changed files with 2338 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
const {
initializaGlobalState,
getCoreSupportsOnPluginInit,
} = require('./options');
const {
createCloudinaryAssetType,
createCloudinaryAssetNodes,
} = require('./node-creation');
const { createGatsbyImageDataResolver } = require('./gatsby-plugin-image');
let coreSupportsOnPluginInit = getCoreSupportsOnPluginInit();
if (coreSupportsOnPluginInit === 'stable') {
exports.onPluginInit = initializaGlobalState;
} else if (coreSupportsOnPluginInit === 'unstable') {
exports.unstable_onPluginInit = initializaGlobalState;
} else {
exports.onPreInit = initializaGlobalState;
}
exports.pluginOptionsSchema = ({ Joi }) => {
return Joi.object({
cloudName: Joi.string(),
apiKey: Joi.string(),
apiSecret: Joi.string(),
uploadFolder: Joi.string(),
uploadSourceInstanceNames: Joi.array().items(Joi.string()),
transformTypes: Joi.array()
.items(Joi.string())
.default(['CloudinaryAsset']),
overwriteExisting: Joi.boolean().default(false),
defaultTransformations: Joi.array()
.items(Joi.string())
.default(['c_fill', 'g_auto', 'q_auto']),
});
};
exports.createSchemaCustomization = (gatsbyUtils) => {
// Type to be used for node creation
createCloudinaryAssetType(gatsbyUtils);
};
exports.createResolvers = (gatsbyUtils, pluginOptions) => {
// Resolvers to be used with gatsby-plugin-image
createGatsbyImageDataResolver(gatsbyUtils, pluginOptions);
};
exports.onCreateNode = async (gatsbyUtils, pluginOptions) => {
// Upload and create Cloudinary Asset nodes if applicable
await createCloudinaryAssetNodes(gatsbyUtils, pluginOptions);
};

View File

@@ -0,0 +1,65 @@
import Joi from 'joi';
import { testPluginOptionsSchema } from 'gatsby-plugin-utils';
import { pluginOptionsSchema } from './gatsby-node';
describe('pluginOptionsSchema', () => {
test('should validate minimal correct options', async () => {
// cloudName, apiKey, apiSecret
// only needed if uploading
const options = {};
const { isValid } = await testPluginOptionsSchema(
pluginOptionsSchema,
options
);
expect(isValid).toBe(true);
});
test('should invalidate incorrect options', async () => {
const options = {
cloudName: 120,
apiKey: '',
apiSecret: false,
uploadFolder: ['test'],
uploadSourceInstanceNames: 'instanceName',
transformTypes: [123],
overwriteExisting: 3,
defaultTransformations: null,
};
const { isValid, errors } = await testPluginOptionsSchema(
pluginOptionsSchema,
options
);
expect(isValid).toBe(false);
expect(errors).toEqual([
`"cloudName" must be a string`,
`"apiKey" is not allowed to be empty`,
`"apiSecret" must be a string`,
`"uploadFolder" must be a string`,
`"uploadSourceInstanceNames" must be an array`,
`"transformTypes[0]" must be a string`,
`"overwriteExisting" must be a boolean`,
`"defaultTransformations" must be an array`,
]);
});
test('should add defaults', async () => {
const schema = pluginOptionsSchema({ Joi });
const options = {
cloudName: 'cloudName',
apiKey: 'apiKey',
apiSecret: 'apiSecret',
};
const { value } = schema.validate(options);
expect(value).toEqual({
...options,
transformTypes: ['CloudinaryAsset'],
overwriteExisting: false,
defaultTransformations: ['c_fill', 'g_auto', 'q_auto'],
});
});
});

View File

@@ -0,0 +1,63 @@
const axios = require('axios');
const probe = require('probe-image-size');
const { generateCloudinaryAssetUrl } = require('./generate-asset-url');
const dataCache = {};
const probeCache = {};
const getData = async (url, options) => {
if (!dataCache[url]) {
dataCache[url] = axios.get(url, options);
}
const { data } = await dataCache[url];
return data;
};
const probeImage = async (url) => {
if (!probeCache[url]) {
probeCache[url] = probe(url);
}
return await probeCache[url];
};
exports.getAssetAsTracedSvg = async ({ source, args }) => {
const svgUrl = generateCloudinaryAssetUrl({
publicId: source.publicId,
cloudName: source.cloudName,
format: 'svg',
options: args,
tracedSvg: {
options: {
colors: 2,
detail: 0.3,
despeckle: 0.1,
},
width: 300,
},
});
const data = await getData(svgUrl);
return `data:image/svg+xml,${encodeURIComponent(data)}`;
};
exports.getAssetMetadata = async ({ source, args }) => {
const metaDataUrl = generateCloudinaryAssetUrl({
publicId: source.publicId,
cloudName: source.cloudName,
options: args,
});
const result = await probeImage(metaDataUrl);
return {
width: result.width,
height: result.height,
format: result.type,
};
};
exports.getUrlAsBase64Image = async (url) => {
const data = await getData(url, { responseType: 'arraybuffer' });
const base64 = Buffer.from(data, 'binary').toString('base64');
return `data:image/jpeg;base64,${base64}`;
};

View File

@@ -0,0 +1,125 @@
jest.mock('axios');
jest.mock('probe-image-size');
const axios = require('axios');
const probe = require('probe-image-size');
const {
getAssetMetadata,
getUrlAsBase64Image,
getAssetAsTracedSvg,
} = require('./asset-data');
const source = {
publicId: 'public-id',
cloudName: 'cloud-name',
};
const args = {
transformations: ['e_grayscale'],
};
describe('getAssetMetaData', () => {
beforeEach(() => {
probe.mockResolvedValue({
width: 400,
height: 300,
type: 'jpg',
extra: 'extra',
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('fetches the correct metadata url', async () => {
await getAssetMetadata({ source, args });
expect(probe).toHaveBeenCalledWith(
expect.stringContaining(
'res.cloudinary.com/cloud-name/image/upload/f_auto,e_grayscale/public-id'
)
);
});
it('returns the metadata', async () => {
const metadata = await getAssetMetadata({ source, args });
expect(metadata).toStrictEqual({ width: 400, height: 300, format: 'jpg' });
});
it('to cache responses', async () => {
await getAssetMetadata({ source, args: {} });
await getAssetMetadata({ source, args: {} });
await getAssetMetadata({ source, args: { chained: ['t_lwj'] } });
await getAssetMetadata({ source, args: { chained: ['t_lwj'] } });
expect(probe).toHaveBeenCalledTimes(2);
});
});
describe('getUrlAsBase64Image', () => {
beforeEach(() => {
axios.get.mockResolvedValue({
data: '49 27 6d 20 61 20 73 74 72 69 6e 67 21',
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('fetches an arraybuffer from the url', async () => {
await getUrlAsBase64Image('example-url');
expect(axios.get).toHaveBeenCalledWith(`example-url`, {
responseType: 'arraybuffer',
});
});
it('returns the base64 data url', async () => {
const base64 = await getUrlAsBase64Image('example-url');
expect(base64).toBe(
'data:image/jpeg;base64,NDkgMjcgNmQgMjAgNjEgMjAgNzMgNzQgNzIgNjkgNmUgNjcgMjE='
);
});
it('to cache responses', async () => {
await getUrlAsBase64Image('one-url');
await getUrlAsBase64Image('one-url');
await getUrlAsBase64Image('another-url');
await getUrlAsBase64Image('another-url');
expect(axios.get).toHaveBeenCalledTimes(2);
});
});
describe('getAssetAsTracedSvg', () => {
beforeEach(() => {
axios.get.mockResolvedValue({
data: '<svg path.....>',
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('fetches the correct url', async () => {
await getAssetAsTracedSvg({ source, args });
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining(
'res.cloudinary.com/cloud-name/image/upload/f_svg,e_grayscale/e_vectorize:colors:2:detail:0.3:despeckle:0.1,w_300/public-id'
),
undefined
);
});
it('returns the svg as data url', async () => {
const svg = await getAssetAsTracedSvg({ source, args });
expect(svg).toBe('data:image/svg+xml,%3Csvg%20path.....%3E');
});
it('to cache responses', async () => {
await getAssetAsTracedSvg({ source, args: {} });
await getAssetAsTracedSvg({ source, args: {} });
await getAssetAsTracedSvg({ source, args: { chained: ['t_lwj'] } });
await getAssetAsTracedSvg({ source, args: { chained: ['t_lwj'] } });
expect(axios.get).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,69 @@
const cloudinary = require('cloudinary').v2;
const pluginPkg = require('../package.json');
const gatsbyPkg = require('gatsby/package.json');
const SDK_CODE = 'X';
const SDK_SEMVER = pluginPkg.version;
const TECH_VERSION = gatsbyPkg.version;
const generateTransformations = ({ width, height, format, options = {} }) => {
return [
{
fetch_format: format || 'auto',
width: width,
height: height,
raw_transformation: (options.transformations || []).join(','),
},
...(options.chained || []).map((transformations) => {
return { raw_transformation: transformations };
}),
];
};
const generateTracedSVGTransformation = ({ options, width }) => {
const effectOptions = Object.keys(options).reduce((acc, key) => {
const value = options[key];
return value ? acc + `:${key}:${value}` : acc;
}, 'vectorize');
return {
effect: effectOptions,
width: width,
};
};
// Create Cloudinary image URL with transformations.
exports.generateCloudinaryAssetUrl = ({
publicId,
cloudName,
width,
height,
format,
options = {},
flags,
tracedSvg,
}) => {
const transformation = generateTransformations({
width,
height,
format,
options,
});
if (tracedSvg) {
transformation.push(generateTracedSVGTransformation(tracedSvg));
}
const url = cloudinary.url(publicId, {
cloud_name: cloudName,
secure: options.secure,
transformation: transformation,
flags: flags,
urlAnalytics: true,
sdkCode: SDK_CODE,
sdkSemver: SDK_SEMVER,
techVersion: TECH_VERSION,
});
return url;
};

View File

@@ -0,0 +1,80 @@
jest.mock('../package.json', () => ({
version: '0.1.2',
}));
jest.mock('gatsby/package.json', () => ({
version: '0.5.3',
}));
const { generateCloudinaryAssetUrl } = require('./generate-asset-url');
const ANALYTICS_CODE = 'AXE6EH00';
describe('generateCloudinaryAssetUrl', () => {
const asset = {
publicId: 'public-id',
cloudName: 'cloud-name',
width: 400,
height: 600,
format: 'jpg',
};
it('generates correct Cloudinary url when no options', () => {
const url = generateCloudinaryAssetUrl(asset);
expect(url).toBe(
`http://res.cloudinary.com/cloud-name/image/upload/f_jpg,h_600,w_400/public-id?_a=${ANALYTICS_CODE}`
);
});
it('generates correct Cloudinary url with transformations option', () => {
const url = generateCloudinaryAssetUrl({
...asset,
options: {
transformations: ['e_grayscale', 'e_pixelate'],
},
});
expect(url).toBe(
`http://res.cloudinary.com/cloud-name/image/upload/f_jpg,h_600,w_400,e_grayscale,e_pixelate/public-id?_a=${ANALYTICS_CODE}`
);
});
it('generates correct Cloudinary url with chained option', () => {
const url = generateCloudinaryAssetUrl({
...asset,
options: {
chained: ['t_lwj', 'e_pixelate'],
},
});
expect(url).toBe(
`http://res.cloudinary.com/cloud-name/image/upload/f_jpg,h_600,w_400/t_lwj/e_pixelate/public-id?_a=${ANALYTICS_CODE}`
);
});
it('generates correct Cloudinary url with scure option set to true', () => {
const url = generateCloudinaryAssetUrl({
...asset,
options: {
secure: true,
},
});
expect(url).toBe(
`https://res.cloudinary.com/cloud-name/image/upload/f_jpg,h_600,w_400/public-id?_a=${ANALYTICS_CODE}`
);
});
it('generates correct Cloudinary url in traced SVG mode', () => {
const url = generateCloudinaryAssetUrl({
...asset,
tracedSvg: {
options: {
colors: 2,
detail: 0.3,
despeckle: 0.1,
},
width: 300,
},
});
expect(url).toBe(
`http://res.cloudinary.com/cloud-name/image/upload/f_jpg,h_600,w_400/e_vectorize:colors:2:detail:0.3:despeckle:0.1,w_300/public-id?_a=${ANALYTICS_CODE}`
);
});
});

View File

@@ -0,0 +1,26 @@
const { createGatsbyPluginImageResolver } = require('./resolvers');
exports.createGatsbyImageDataResolver = (gatsbyUtils, pluginOptions) => {
const { createResolvers } = gatsbyUtils;
const { transformTypes } = pluginOptions;
const gatsbyImageResolver = createGatsbyPluginImageResolver(
gatsbyUtils,
pluginOptions
);
if (gatsbyImageResolver) {
const resolvers = {};
// Make the resolver nullable, createGatsbyPluginImageResolver sets the type to 'GatsbyImageData!'
gatsbyImageResolver.type = 'GatsbyImageData';
transformTypes.forEach((type) => {
// Add gatsbyImageData resolver
// to all types that should be transformed
resolvers[type] = {
gatsbyImageData: gatsbyImageResolver,
};
});
createResolvers(resolvers);
}
};

View File

@@ -0,0 +1,175 @@
const {
getLowResolutionImageURL,
generateImageData,
} = require('gatsby-plugin-image');
const { generateCloudinaryAssetUrl } = require('./generate-asset-url');
const {
getAssetAsTracedSvg,
getUrlAsBase64Image,
getAssetMetadata,
} = require('./asset-data');
const { Joi } = require('gatsby-plugin-utils/joi');
const { resolverReporter } = require('./resolver-reporter');
const generateCloudinaryAssetSource = (
filename,
width,
height,
format,
_fit,
options
) => {
const [cloudName, publicId] = filename.split('>>>');
const cloudinarySrcUrl = generateCloudinaryAssetUrl({
cloudName: cloudName,
publicId: publicId,
width,
height,
format,
options,
});
const imageSource = {
src: cloudinarySrcUrl,
width: width,
height: height,
format: format,
};
return imageSource;
};
const generateMetadata = async (source, args, transformType, reporter) => {
const schema = Joi.object({
width: Joi.number().positive().required(),
height: Joi.number().positive().required(),
format: Joi.string().default('auto'),
}).required();
const originalMetadata = {
width: source.originalWidth,
height: source.originalHeight,
format: source.originalFormat,
};
const { value, error } = schema.validate(originalMetadata);
if (!error) {
// Original metadata is valid,
// use validated value
return value;
}
try {
// Lacking metadata, so let's fetch it
reporter.verbose(
`[gatsby-transformer-cloudinary] Missing metadata fields on ${transformType}: cloudName=${source.cloudName}, publicId=${source.publicId} >>> To save on network requests add originalWidth, originalHeight and originalFormat to ${transformType}`
);
const fetchedMetadata = await getAssetMetadata({ source, args });
const { value, error } = schema.validate(fetchedMetadata);
if (!error) {
// Fetched metadata is valid,
// use validated value
return value;
} else {
// Fetched metadata is not valid
reporter.verbose(
`[gatsby-transformer-cloudinary] Invalid fetched metadata for ${transformType}: cloudName=${source.cloudName}, publicId=${source.publicId} >>> ${error.message}`
);
return null;
}
} catch (error) {
// Error fetching
reporter.verbose(
`[gatsby-transformer-cloudinary] Could not fetch metadata for ${transformType}: cloudName=${source.cloudName}, publicId=${source.publicId} >>> ${error.message}`
);
return null;
}
};
// Make it testable
exports._generateCloudinaryAssetSource = generateCloudinaryAssetSource;
exports.createResolveCloudinaryAssetData =
(gatsbyUtils) => async (source, args, _context, info) => {
let { reporter } = gatsbyUtils;
reporter = resolverReporter({ reporter, logLevel: args.logLevel });
const transformType = info.parentType || 'UnknownTransformType';
const schema = Joi.object({
cloudName: Joi.string().required(),
publicId: Joi.string().required(),
}).required();
const { error } = schema.validate(source, {
allowUnknown: true,
abortEarly: false,
});
if (error) {
if (error.details.length < 2 && error.details[0].path.length > 0) {
reporter.warn(
`[gatsby-transformer-cloudinary] Missing required field on ${transformType}: cloudName=${source?.cloudName}, publicId=${source?.publicId} >>> gatsbyImageData will resolve to null`
);
} else {
reporter.verbose(
`[gatsby-transformer-cloudinary] Missing cloudName and publicId on ${transformType} >>> gatsbyImageData will resolve to null`
);
}
return null;
}
const metadata = await generateMetadata(
source,
args,
transformType,
reporter
);
if (!metadata) {
reporter.warn(
`[gatsby-transformer-cloudinary] No metadata for ${transformType}: cloudName=${source.cloudName}, publicId=${source.publicId} >>> gatsbyImageData will resolve to null`
);
return null;
}
const assetDataArgs = {
...args,
filename: source.cloudName + '>>>' + source.publicId,
// Passing the plugin name allows for better error messages
pluginName: `gatsby-transformer-cloudinary`,
sourceMetadata: metadata,
generateImageSource: generateCloudinaryAssetSource,
options: args,
};
try {
if (args.placeholder === 'blurred') {
if (source.defaultBase64) {
assetDataArgs.placeholderURL = source.defaultBase64;
} else {
const lowResolutionUrl = getLowResolutionImageURL(assetDataArgs);
const base64 = await getUrlAsBase64Image(lowResolutionUrl);
assetDataArgs.placeholderURL = base64;
}
} else if (args.placeholder === 'tracedSVG') {
if (source.defaultTracedSVG) {
assetDataArgs.placeholderURL = source.defaultTracedSVG;
} else {
const tracedSvg = await getAssetAsTracedSvg({ source, args });
assetDataArgs.placeholderURL = tracedSvg;
}
}
} catch (error) {
reporter.error(
`[gatsby-transformer-cloudinary] Could not generate placeholder (${args.placeholder}) for ${source.cloudName} > ${source.publicId}: ${error.message}`
);
}
return generateImageData(assetDataArgs);
};

View File

@@ -0,0 +1,470 @@
jest.mock('gatsby-plugin-image');
jest.mock('./asset-data');
const gatsbyUtilsMocks = {
reporter: {
panic: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
},
};
const {
getLowResolutionImageURL,
generateImageData,
} = require('gatsby-plugin-image');
const {
getAssetAsTracedSvg,
getAssetMetadata,
getUrlAsBase64Image,
} = require('./asset-data');
const {
_generateCloudinaryAssetSource,
createResolveCloudinaryAssetData,
} = require('./resolve-asset');
const resolveCloudinaryAssetData =
createResolveCloudinaryAssetData(gatsbyUtilsMocks);
describe('generateCloudinaryAssetSource', () => {
const filename = 'cloud-name>>>public-id';
const width = 300;
const height = 500;
const format = 'jpg';
const fit = undefined;
const options = {
chained: ['t_lwj'],
secure: true,
};
it('generated correct source data', () => {
const result = _generateCloudinaryAssetSource(
filename,
width,
height,
format,
fit,
options
);
expect(result.src).toContain(
'https://res.cloudinary.com/cloud-name/image/upload/f_jpg,h_500,w_300/t_lwj/public-id'
);
expect(result.width).toBe(width);
expect(result.height).toBe(height);
expect(result.format).toBe(format);
});
});
describe('resolveCloudinaryAssetData', () => {
const sourceWithMetadata = {
publicId: 'public-id',
cloudName: 'cloud-name',
originalWidth: '600',
originalHeight: '300',
originalFormat: 'jpg',
};
const sourceWithoutFormat = {
publicId: 'public-id',
cloudName: 'cloud-name',
originalWidth: '600',
originalHeight: '300',
};
const sourceWithoutMeta = {
publicId: 'public-id',
cloudName: 'cloud-name',
};
const context = {}; // Never used
const info = {};
beforeEach(() => {
getAssetMetadata.mockResolvedValue({
width: 100,
height: 200,
format: 'gif',
});
getUrlAsBase64Image.mockResolvedValue('base64DataUrl');
getAssetAsTracedSvg.mockResolvedValue('svgDataUrl');
getLowResolutionImageURL.mockResolvedValue('low-resultion-url');
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls gatsby-plugin-image -> generateImageData once', async () => {
const args = {};
await resolveCloudinaryAssetData(sourceWithMetadata, args, context, info);
expect(generateImageData).toBeCalledTimes(1);
});
it('calls gatsby-plugin-image -> generateImageData with correct data', async () => {
const args = { transformations: ['e_grayscale'] };
await resolveCloudinaryAssetData(sourceWithMetadata, args, context, info);
await resolveCloudinaryAssetData(sourceWithoutFormat, args, context, info);
expect(getAssetMetadata).toBeCalledTimes(0);
expect(generateImageData).toHaveBeenNthCalledWith(1, {
filename: 'cloud-name>>>public-id',
generateImageSource: _generateCloudinaryAssetSource,
options: {
transformations: ['e_grayscale'],
},
pluginName: 'gatsby-transformer-cloudinary',
sourceMetadata: {
format: 'jpg',
height: 300,
width: 600,
},
transformations: ['e_grayscale'],
});
expect(generateImageData).toHaveBeenNthCalledWith(2, {
filename: 'cloud-name>>>public-id',
generateImageSource: _generateCloudinaryAssetSource,
options: {
transformations: ['e_grayscale'],
},
pluginName: 'gatsby-transformer-cloudinary',
sourceMetadata: {
format: 'auto',
height: 300,
width: 600,
},
transformations: ['e_grayscale'],
});
});
it('fetches metadata when not present on source', async () => {
const args = {};
await resolveCloudinaryAssetData(sourceWithMetadata, args, context, info);
await resolveCloudinaryAssetData(sourceWithoutMeta, args, context, info);
// getAssetMetadata should only be called for sourceWithoutMeta
expect(getAssetMetadata).toBeCalledTimes(1);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(1);
// gatsby-plugin-image -> generateImageData should be called for both
expect(generateImageData).toBeCalledTimes(2);
expect(generateImageData).toHaveBeenNthCalledWith(2, {
filename: 'cloud-name>>>public-id',
generateImageSource: _generateCloudinaryAssetSource,
options: {},
pluginName: 'gatsby-transformer-cloudinary',
sourceMetadata: {
format: 'gif',
height: 200,
width: 100,
},
});
});
it('fetches and adds correct "blurred" placeholder', async () => {
const args = { placeholder: 'blurred' };
await resolveCloudinaryAssetData(sourceWithMetadata, args, context, info);
expect(getLowResolutionImageURL).toBeCalledTimes(1);
expect(getUrlAsBase64Image).toBeCalledTimes(1);
expect(generateImageData).toHaveBeenCalledWith({
filename: 'cloud-name>>>public-id',
generateImageSource: _generateCloudinaryAssetSource,
options: { placeholder: 'blurred' },
pluginName: 'gatsby-transformer-cloudinary',
sourceMetadata: {
format: 'jpg',
height: 300,
width: 600,
},
placeholderURL: 'base64DataUrl',
placeholder: 'blurred',
});
});
it('fetches and adds correct "tracedSVG" placeholder', async () => {
const args = { placeholder: 'tracedSVG' };
await resolveCloudinaryAssetData(sourceWithMetadata, args, context, info);
expect(getAssetAsTracedSvg).toBeCalledTimes(1);
expect(generateImageData).toHaveBeenCalledWith({
filename: 'cloud-name>>>public-id',
generateImageSource: _generateCloudinaryAssetSource,
options: { placeholder: 'tracedSVG' },
pluginName: 'gatsby-transformer-cloudinary',
sourceMetadata: {
format: 'jpg',
height: 300,
width: 600,
},
placeholderURL: 'svgDataUrl',
placeholder: 'tracedSVG',
});
});
describe('when unconforming source', () => {
it('calls reporter.verbose and returns null for empty source', async () => {
const source = {};
const args = {};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(1);
expect(result).toBe(null);
});
describe('returns null for weird source', () => {
it('calls reporter.verbose when undefined log level', async () => {
const source = {
one: 'thing',
another: 'thang',
};
const args = {};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(1);
expect(result).toBe(null);
});
it('calls reporter.verbose when log level = "verbose"', async () => {
const source = {
one: 'thing',
another: 'thang',
};
const args = {
logLevel: 'verbose',
};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(1);
expect(result).toBe(null);
});
it('does not call reporter.verbose when log level = "warn"', async () => {
const source = {
one: 'thing',
another: 'thang',
};
const args = {
logLevel: 'warn',
};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(0);
expect(result).toBe(null);
});
});
it('calls reporter.verbose and returns null for null source', async () => {
const source = null;
const args = {};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(1);
expect(result).toBe(null);
});
it('calls reporter.verbose and returns null for undefined source', async () => {
const source = undefined;
const args = {};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(1);
expect(result).toBe(null);
});
describe('returns null for missing data', () => {
it('returns null for missing data when log level is undefined', async () => {
const source = {
publicId: 'publicId',
one: 'thing',
};
const args = {};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledTimes(1);
expect(result).toBe(null);
});
it('does not call reporter.warn when log level = "error"', async () => {
const source = {
publicId: 'publicId',
one: 'thing',
};
const args = {
logLevel: 'error',
};
const result = await resolveCloudinaryAssetData(
source,
args,
context,
info
);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledTimes(0);
expect(result).toBe(null);
});
});
});
describe('when fetched asset data is invalid', () => {
beforeEach(() => {
getAssetMetadata.mockResolvedValue({
width: 100,
// height: 200,
// format: 'gif',
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls reporter.warn on invalid metadata and returns null', async () => {
const args = {};
const result = await resolveCloudinaryAssetData(
sourceWithoutMeta,
args,
context,
info
);
expect(getAssetMetadata).toBeCalledTimes(1);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledTimes(1);
expect(result).toBe(null);
});
});
describe('when fetched asset data is undefined', () => {
beforeEach(() => {
getAssetMetadata.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls reporter.warn on undefined metadata and returns null', async () => {
const args = {};
const result = await resolveCloudinaryAssetData(
sourceWithoutMeta,
args,
context,
info
);
expect(getAssetMetadata).toBeCalledTimes(1);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledTimes(1);
expect(result).toBe(null);
});
});
describe('when error fetching asset data', () => {
beforeEach(() => {
getAssetMetadata.mockImplementation(() => {
throw new Error();
});
getUrlAsBase64Image.mockImplementation(() => {
throw new Error();
});
getAssetAsTracedSvg.mockImplementation(() => {
throw new Error();
});
getLowResolutionImageURL.mockResolvedValue('low-resultion-url');
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls reporter.error on metadata error and returns null', async () => {
const args = {};
const result = await resolveCloudinaryAssetData(
sourceWithoutMeta,
args,
context,
info
);
expect(getAssetMetadata).toBeCalledTimes(1);
expect(generateImageData).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledTimes(1);
expect(result).toBe(null);
});
it('calls reporter.error on blurred placeholder error', async () => {
const args = { placeholder: 'blurred' };
await resolveCloudinaryAssetData(sourceWithMetadata, args, context, info);
expect(getLowResolutionImageURL).toBeCalledTimes(1);
expect(getUrlAsBase64Image).toBeCalledTimes(1);
expect(gatsbyUtilsMocks.reporter.error).toBeCalledTimes(1);
expect(generateImageData).toHaveBeenCalledWith({
filename: 'cloud-name>>>public-id',
generateImageSource: _generateCloudinaryAssetSource,
options: { placeholder: 'blurred' },
pluginName: 'gatsby-transformer-cloudinary',
sourceMetadata: {
format: 'jpg',
height: 300,
width: 600,
},
// placeholderURL: 'base64DataUrl',
placeholder: 'blurred',
});
});
it('calls reporter.error on tracedSVG placeholder error', async () => {
const args = { placeholder: 'tracedSVG' };
await resolveCloudinaryAssetData(sourceWithMetadata, args, context, info);
expect(getAssetAsTracedSvg).toBeCalledTimes(1);
expect(gatsbyUtilsMocks.reporter.error).toBeCalledTimes(1);
expect(generateImageData).toHaveBeenCalledWith({
filename: 'cloud-name>>>public-id',
generateImageSource: _generateCloudinaryAssetSource,
options: { placeholder: 'tracedSVG' },
pluginName: 'gatsby-transformer-cloudinary',
sourceMetadata: {
format: 'jpg',
height: 300,
width: 600,
},
// placeholderURL: 'svgDataUrl',
placeholder: 'tracedSVG',
});
});
});
});

View File

@@ -0,0 +1,25 @@
const LEVEL = {
verbose: 0,
info: 1,
warn: 2,
error: 3,
panic: 4,
panicOnBuild: 4,
};
exports.resolverReporter = ({ reporter, logLevel }) => {
const log = (level, message, ...rest) => {
if (!logLevel || LEVEL[level] >= LEVEL[logLevel]) {
reporter[level](message, ...rest);
}
};
return {
verbose: (...args) => log('verbose', ...args),
info: (...args) => log('info', ...args),
warn: (...args) => log('warn', ...args),
error: (...args) => log('error', ...args),
panic: (...args) => log('panic', ...args),
panicOnBuild: (...args) => log('panicOnBuild', ...args),
};
};

View File

@@ -0,0 +1,143 @@
const { resolverReporter } = require('./resolver-reporter');
const gatsbyUtilsMocks = {
reporter: {
panicOnBuild: jest.fn(),
panic: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
verbose: jest.fn(),
},
};
describe('resolverReporter', () => {
it('reports only panic', () => {
const reporter = resolverReporter({
reporter: gatsbyUtilsMocks.reporter,
logLevel: 'panic',
});
reporter.panicOnBuild('message');
reporter.panic('message');
reporter.error('message');
reporter.warn('message');
reporter.info('message');
reporter.verbose('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panicOnBuild).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.error).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.info).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(0);
});
it('reports only errors and above', () => {
const reporter = resolverReporter({
reporter: gatsbyUtilsMocks.reporter,
logLevel: 'error',
});
reporter.panicOnBuild('message');
reporter.panic('message');
reporter.error('message');
reporter.warn('message');
reporter.info('message');
reporter.verbose('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panicOnBuild).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.error).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.info).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(0);
});
it('reports only warnings and above', () => {
const reporter = resolverReporter({
reporter: gatsbyUtilsMocks.reporter,
logLevel: 'warn',
});
reporter.panicOnBuild('message');
reporter.panic('message');
reporter.error('message');
reporter.warn('message');
reporter.info('message');
reporter.verbose('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panicOnBuild).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.error).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.info).toBeCalledTimes(0);
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(0);
});
it('reports only info and above', () => {
const reporter = resolverReporter({
reporter: gatsbyUtilsMocks.reporter,
logLevel: 'info',
});
reporter.panicOnBuild('message');
reporter.panic('message');
reporter.error('message');
reporter.warn('message');
reporter.info('message');
reporter.verbose('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panicOnBuild).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.error).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.info).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledTimes(0);
});
describe('reports all', () => {
it('when log level is verbose', () => {
const reporter = resolverReporter({
reporter: gatsbyUtilsMocks.reporter,
logLevel: 'verbose',
});
reporter.panicOnBuild('message');
reporter.panic('message');
reporter.error('message');
reporter.warn('message');
reporter.info('message');
reporter.verbose('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panicOnBuild).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.error).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.info).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledWith('message');
});
it('when log level is undefined', () => {
const reporter = resolverReporter({
reporter: gatsbyUtilsMocks.reporter,
});
reporter.panicOnBuild('message');
reporter.panic('message');
reporter.error('message');
reporter.warn('message');
reporter.info('message');
reporter.verbose('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panic).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.panicOnBuild).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.error).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.warn).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.info).toBeCalledWith('message');
expect(gatsbyUtilsMocks.reporter.verbose).toBeCalledWith('message');
});
});
});

View File

@@ -0,0 +1,35 @@
exports.createGatsbyPluginImageResolver = (gatsbyUtils, pluginOptions) => {
const { reporter } = gatsbyUtils;
try {
const {
getGatsbyImageResolver,
} = require('gatsby-plugin-image/graphql-utils');
const { createResolveCloudinaryAssetData } = require('./resolve-asset');
const { CloudinaryPlaceholderType } = require('./types');
return getGatsbyImageResolver(
createResolveCloudinaryAssetData(gatsbyUtils),
{
transformations: {
type: '[String]',
defaultValue: pluginOptions.defaultTransformations,
},
chained: '[String]',
placeholder: {
type: CloudinaryPlaceholderType,
},
secure: {
type: 'Boolean',
defaultValue: true,
},
logLevel: {
type: 'String',
},
}
);
} catch (error) {
reporter.warn(
'[gatsby-transformer-cloudinary] Install and configure gatsby-plugin-image to use the new GatsbyImage component and gatsbyImageData resolver'
);
}
};

View File

@@ -0,0 +1,10 @@
const { GraphQLEnumType } = require('gatsby/graphql');
exports.CloudinaryPlaceholderType = new GraphQLEnumType({
name: `CloudinaryPlaceholder`,
values: {
TRACED_SVG: { value: `tracedSVG` },
BLURRED: { value: `blurred` },
NONE: { value: `none` },
},
});

View File

@@ -0,0 +1,25 @@
import { Node, NodePluginArgs, Reporter } from 'gatsby';
export function createRemoteImageNode(
args: CreateRemoteImageNodeArgs
): Promise<CloudinaryAssetNode>;
export interface CreateRemoteImageNodeArgs {
url: string;
parentNode: Node;
overwriteExisting?: boolean;
createContentDigest: NodePluginArgs['createContentDigest'];
createNode: NodePluginArgs['actions']['createNode'];
createNodeId: NodePluginArgs['createNodeId'];
reporter: Reporter;
}
export interface CloudinaryAssetNode extends Node {
cloudName: string;
publicId: string;
version?: number;
originalHeight?: number;
originalWidth?: number;
originalFormat?: string;
rawCloudinaryData: Object;
}

View File

@@ -0,0 +1,2 @@
exports.createRemoteImageNode =
require('./node-creation/create-remote-image-node').createRemoteImageNode;

View File

@@ -0,0 +1,51 @@
const { uploadImageNodeToCloudinary } = require('./upload');
const { createImageNode } = require('./create-image-node');
const ALLOWED_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
exports.createAssetNodeFromFile = async (gatsbyUtils, pluginOptions) => {
const {
node,
actions: { createNode, createParentChildLink },
createNodeId,
createContentDigest,
reporter,
} = gatsbyUtils;
const { uploadSourceInstanceNames } = pluginOptions || {};
if (!ALLOWED_MEDIA_TYPES.includes(node.internal.mediaType)) {
return;
}
if (
uploadSourceInstanceNames &&
!uploadSourceInstanceNames.includes(node.sourceInstanceName)
) {
return;
}
const cloudinaryUploadResult = await uploadImageNodeToCloudinary({
node,
reporter,
});
const imageNode = createImageNode({
cloudinaryUploadResult,
parentNode: node,
createContentDigest,
createNode,
createNodeId,
});
// Add the new node to Gatsbys data layer.
createNode(imageNode);
// Tell Gatsby to add `childCloudinaryAsset` to the parent `File` node.
createParentChildLink({
parent: node,
child: imageNode,
});
return imageNode;
};

View File

@@ -0,0 +1,61 @@
jest.mock('./upload');
jest.mock('./create-image-node');
const { createAssetNodeFromFile } = require('./create-asset-node-from-file');
const { uploadImageNodeToCloudinary } = require('./upload');
const { createImageNode } = require('./create-image-node');
const gatsbyUtilsMock = {
actions: { createNode: jest.fn(), createParentChildLink: jest.fn() },
createNodeId: jest.fn(),
createContentDigest: jest.fn(),
reporter: jest.fn(),
};
describe('createAssetNodeFromFile', () => {
beforeEach(() => {
createImageNode.mockReturnValue({ id: 'image-node' });
});
afterEach(() => {
jest.clearAllMocks();
});
test('creates an image node and connects it to the node', async () => {
const node = {
id: 'node',
internal: { mediaType: 'image/png' },
};
await createAssetNodeFromFile({ node, ...gatsbyUtilsMock });
expect(createImageNode).toHaveBeenCalledTimes(1);
expect(gatsbyUtilsMock.actions.createParentChildLink).toBeCalledTimes(1);
expect(gatsbyUtilsMock.actions.createParentChildLink).toBeCalledWith({
parent: node,
child: { id: 'image-node' },
});
});
test('does not create an image node for invalid media type', async () => {
const node = {
id: 'node',
internal: { mediaType: 'image/svg' },
};
await createAssetNodeFromFile({ node, ...gatsbyUtilsMock });
expect(createImageNode).toHaveBeenCalledTimes(0);
expect(gatsbyUtilsMock.actions.createParentChildLink).toBeCalledTimes(0);
});
test('does not create an image node for invalid sourceInstanceName', async () => {
const node = {
id: 'node',
sourceInstanceName: '__PROGRAMMATIC__',
internal: { mediaType: 'image/png' },
};
await createAssetNodeFromFile(
{ node, ...gatsbyUtilsMock },
{ uploadSourceInstanceNames: ['cloudinary'] }
);
expect(createImageNode).toHaveBeenCalledTimes(0);
expect(gatsbyUtilsMock.actions.createParentChildLink).toBeCalledTimes(0);
});
});

View File

@@ -0,0 +1,48 @@
const stringify = require('fast-json-stable-stringify');
const { getPluginOptions } = require('../options');
exports.createImageNode = ({
cloudinaryUploadResult,
parentNode,
createContentDigest,
createNodeId,
cloudName,
defaultBase64,
defaultTracedSVG,
}) => {
const { public_id, height, width, version, format } = cloudinaryUploadResult;
const fingerprint = stringify({
cloudName,
height,
public_id,
version,
width,
});
const imageNode = {
// These helper fields are only here so the resolvers have access to them.
// They will *not* be available via Gatsbys data layer.
cloudName: cloudName || getPluginOptions().cloudName,
publicId: public_id,
version: version,
originalHeight: height,
originalWidth: width,
originalFormat: format,
rawCloudinaryData: cloudinaryUploadResult,
defaultBase64,
defaultTracedSVG,
// Add the required internal Gatsby node fields.
id: createNodeId(`CloudinaryAsset-${fingerprint}`),
parent: parentNode.id,
internal: {
type: 'CloudinaryAsset',
// Gatsby uses the content digest to decide when to reprocess a given
// node. We can use the Cloudinary URL to avoid doing extra work.
contentDigest: createContentDigest(cloudinaryUploadResult),
},
};
return imageNode;
};

View File

@@ -0,0 +1,181 @@
const { createImageNode } = require('./create-image-node');
jest.mock('../options');
const { getPluginOptions } = require('../options');
describe('createImageNode', () => {
function getDefaultArgs(args) {
return {
cloudinaryUploadResult: {},
parentNode: {},
createContentDigest: jest.fn(),
createNodeId: jest.fn(),
...args,
};
}
function getDefaultOptions(options) {
return {
...options,
};
}
it('sets the cloud name', async () => {
const options = getDefaultOptions({ cloudName: 'cloudName' });
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs();
const actual = createImageNode(args);
const expected = { cloudName: 'cloudName' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('sets the public ID', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({
cloudinaryUploadResult: { public_id: 'public-id' },
});
const actual = createImageNode(args);
const expected = { publicId: 'public-id' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('sets the version', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({
cloudinaryUploadResult: { version: 'version' },
});
const actual = createImageNode(args);
const expected = { version: 'version' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('sets the original height', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({
cloudinaryUploadResult: { height: 'originalHeight' },
});
const actual = createImageNode(args);
const expected = { originalHeight: 'originalHeight' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('sets the original width', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({
cloudinaryUploadResult: { width: 'originalWidth' },
});
const actual = createImageNode(args);
const expected = { originalWidth: 'originalWidth' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('sets the defaultBase64 image', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({
defaultBase64: 'defaultBase64',
});
const actual = createImageNode(args);
const expected = { defaultBase64: 'defaultBase64' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('sets the defaultTracedSVG image', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({
defaultTracedSVG: 'defaultTracedSVG',
});
const actual = createImageNode(args);
const expected = { defaultTracedSVG: 'defaultTracedSVG' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('creates a node ID', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const createNodeId = jest.fn((createNodeIdArg) => {
expect(createNodeIdArg).toEqual(
'CloudinaryAsset-{"cloudName":"cloudName","height":100,"public_id":"public_id","version":7,"width":200}'
);
return 'createNodeIdResult';
});
const args = getDefaultArgs({
createNodeId,
cloudinaryUploadResult: {
height: 100,
public_id: 'public_id',
version: 7,
width: 200,
},
cloudName: 'cloudName',
});
const actual = createImageNode(args);
const expected = { id: 'createNodeIdResult' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('sets the parent', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({
parentNode: { id: 'parentNodeId' },
});
const actual = createImageNode(args);
const expected = { parent: 'parentNodeId' };
expect(actual).toEqual(expect.objectContaining(expected));
});
it('creates the content digest', async () => {
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const cloudinaryUploadResult = {
height: 100,
public_id: 'public_id',
version: 7,
width: 200,
};
const createContentDigest = jest.fn((createContentDigestArg) => {
expect(createContentDigestArg).toEqual(cloudinaryUploadResult);
return 'createContentDigestResult';
});
const args = getDefaultArgs({
createContentDigest,
cloudinaryUploadResult,
cloudName: 'cloudName',
});
const actual = createImageNode(args);
const expected = {
internal: {
type: 'CloudinaryAsset',
contentDigest: 'createContentDigestResult',
},
};
expect(actual).toEqual(expect.objectContaining(expected));
});
});

View File

@@ -0,0 +1,71 @@
const path = require('path');
const { uploadImageToCloudinary } = require('./upload');
const { createImageNode } = require('./create-image-node');
const { getPluginOptions } = require('../options');
exports.createRemoteImageNode = async ({
url,
overwriteExisting,
parentNode,
createContentDigest,
createNode,
createNodeId,
reporter,
}) => {
if (!reporter) {
throw Error(
"`reporter` is a required argument. It's available at `CreateNodeArgs.reporter`."
);
}
if (!url) {
reporter.panic(
'`url` is a required argument. Pass the URL where the image is currently hosted so it can be downloaded by Cloudinary.'
);
}
if (!parentNode) {
reporter.panic(
"`parentNode` is a required argument. This parameter is used to link a newly created node representing the image to a parent node in Gatsby's GraphQL layer."
);
}
if (!createContentDigest) {
reporter.panic(
"`createContentDigest` is a required argument. It's available at `CreateNodeArgs.createContentDigest`."
);
}
if (!createNode) {
reporter.panic(
"`createNode` is a required argument. It's available at `CreateNodeArgs.actions.createNode`."
);
}
if (!createNodeId) {
reporter.panic(
"`createNodeId` is a required argument. It's available at `CreateNodeArgs.createNodeId`."
);
}
const overwrite =
overwriteExisting == null
? getPluginOptions().overwriteExisting
: overwriteExisting;
const publicId = path.parse(url).name;
const cloudinaryUploadResult = await uploadImageToCloudinary({
url,
publicId,
overwrite,
reporter,
});
const imageNode = createImageNode({
cloudinaryUploadResult,
parentNode,
createContentDigest,
createNodeId,
});
// Add the new node to Gatsbys data layer.
createNode(imageNode, { name: 'gatsby-transformer-cloudinary' });
return imageNode;
};

View File

@@ -0,0 +1,174 @@
const path = require('path');
const { createRemoteImageNode } = require('./create-remote-image-node');
jest.mock('./create-image-node');
jest.mock('../options');
jest.mock('./upload');
const { createImageNode } = require('./create-image-node');
const { getPluginOptions } = require('../options');
const { uploadImageToCloudinary } = require('./upload');
function getDefaultArgs(args) {
return {
url: 'https://www.google.com/images/puppy.jpg#anchor?abc=def',
createNode: jest.fn(() => 'createNode'),
createNodeId: jest.fn(() => 'createNodeId'),
createContentDigest: jest.fn(() => 'createContentDigest'),
reporter: {
panic: (msg) => {
throw Error(`[reporter] ${msg}}`);
},
},
parentNode: { id: 'abc-123' },
overwriteExisting: false,
...args,
};
}
describe('createRemoteImageNode', () => {
test('requires url', async () => {
const args = getDefaultArgs();
delete args.url;
await expect(createRemoteImageNode(args)).rejects.toThrow(
'[reporter] `url` is a required argument. Pass the URL where the image is currently hosted so it can be downloaded by Cloudinary.'
);
});
test('requires parentNode', async () => {
const args = getDefaultArgs();
delete args.parentNode;
await expect(createRemoteImageNode(args)).rejects.toThrow(
"[reporter] `parentNode` is a required argument. This parameter is used to link a newly created node representing the image to a parent node in Gatsby's GraphQL layer."
);
});
test('requires createContentDigest', async () => {
const args = getDefaultArgs();
delete args.createContentDigest;
await expect(createRemoteImageNode(args)).rejects.toThrow(
"[reporter] `createContentDigest` is a required argument. It's available at `CreateNodeArgs.createContentDigest`."
);
});
test('requires createNode', async () => {
const args = getDefaultArgs();
delete args.createNode;
await expect(createRemoteImageNode(args)).rejects.toThrow(
"[reporter] `createNode` is a required argument. It's available at `CreateNodeArgs.actions.createNode`."
);
});
test('requires createNodeId', async () => {
const args = getDefaultArgs();
delete args.createNodeId;
await expect(createRemoteImageNode(args)).rejects.toThrow(
"[reporter] `createNodeId` is a required argument. It's available at `CreateNodeArgs.createNodeId`."
);
});
test('requires reporter', async () => {
const args = getDefaultArgs();
delete args.reporter;
await expect(createRemoteImageNode(args)).rejects.toThrow(
"`reporter` is a required argument. It's available at `CreateNodeArgs.reporter`."
);
});
test('calls uploadImageToCloudinary with overwrite from plugin options by default', async () => {
const args = getDefaultArgs();
delete args.overwriteExisting;
const optionOverwrite = 'optionOverwrite';
getPluginOptions.mockReturnValue({ overwriteExisting: optionOverwrite });
createImageNode.mockReturnValue({ id: 'image-node-id' });
await createRemoteImageNode(args);
const expectedArgs = { overwrite: optionOverwrite };
expect(uploadImageToCloudinary).toHaveBeenCalledWith(
expect.objectContaining(expectedArgs)
);
});
test('calls uploadImageToCloudinary with overwrite from args if provided', async () => {
const argsOverwrite = 'argsOverwrite';
const args = getDefaultArgs({ overwriteExisting: argsOverwrite });
const optionOverwrite = 'optionOverwrite';
getPluginOptions.mockReturnValue({ overwriteExisting: optionOverwrite });
createImageNode.mockReturnValue({ id: 'image-node-id' });
await createRemoteImageNode(args);
const expectedArgs = { overwrite: argsOverwrite };
expect(uploadImageToCloudinary).toHaveBeenCalledWith(
expect.objectContaining(expectedArgs)
);
});
test('calls uploadImageToCloudinary with the correct arguments', async () => {
const imageNodeId = 'image-node-id';
createImageNode.mockReturnValue({ id: imageNodeId });
const reporter = 'reporter';
const args = getDefaultArgs({ reporter });
await createRemoteImageNode(args);
const expectedArgs = {
url: args.url,
publicId: path.parse(args.url).name,
reporter,
};
expect(uploadImageToCloudinary).toHaveBeenCalledWith(
expect.objectContaining(expectedArgs)
);
});
test('passes the correct arguments to createImageNode', async () => {
const args = getDefaultArgs();
createImageNode.mockReturnValue({ id: 'image-node-id' });
const cloudinaryUploadResult = 'cloudinaryUploadResult';
uploadImageToCloudinary.mockReturnValue(cloudinaryUploadResult);
await createRemoteImageNode(args);
const expectedArgs = {
cloudinaryUploadResult,
parentNode: args.parentNode,
createContentDigest: args.createContentDigest,
createNodeId: args.createNodeId,
};
expect(createImageNode).toHaveBeenCalledWith(
expect.objectContaining(expectedArgs)
);
});
test("creates an imageNode in Gatsby's GraphQL layer", async () => {
const createNode = jest.fn();
const args = getDefaultArgs({ createNode });
const createImageNodeResult = 'createImageNodeResult';
createImageNode.mockReturnValue(createImageNodeResult);
await createRemoteImageNode(args);
expect(createNode).toHaveBeenCalledWith(createImageNodeResult, {
name: 'gatsby-transformer-cloudinary',
});
});
test('returns the image node that it created', async () => {
const args = getDefaultArgs();
const imageNodeId = 'image-node-id';
const imageNode = { id: imageNodeId };
createImageNode.mockReturnValue(imageNode);
const actual = await createRemoteImageNode(args);
expect(actual).toEqual(imageNode);
});
});

View File

@@ -0,0 +1,18 @@
const { createAssetNodeFromFile } = require('./create-asset-node-from-file');
const { CloudinaryAssetType } = require('./types');
exports.createCloudinaryAssetType = (gatsbyUtils) => {
const { actions } = gatsbyUtils;
actions.createTypes(CloudinaryAssetType);
};
exports.createCloudinaryAssetNodes = async (gatsbyUtils, pluginOptions) => {
// Create nodes for files to be uploaded to cloudinary
if (
pluginOptions.apiKey &&
pluginOptions.apiSecret &&
pluginOptions.cloudName
) {
await createAssetNodeFromFile(gatsbyUtils, pluginOptions);
}
};

View File

@@ -0,0 +1,11 @@
exports.CloudinaryAssetType = `
type CloudinaryAsset implements Node {
id: ID!
publicId: String!
cloudName: String!
version: String
originalWidth: Int
originalHeight: Int
originalFormat: String
}
`;

View File

@@ -0,0 +1,94 @@
const cloudinary = require('cloudinary').v2;
const { getPluginOptions } = require('../options');
let totalImages = 0;
let uploadedImages = 0;
const FIVE_MINUTES = 5 * 60 * 1000;
exports.uploadImageToCloudinary = async ({
url,
publicId,
overwrite,
reporter,
}) => {
verifyRequiredOptions(reporter);
const { apiKey, apiSecret, cloudName, uploadFolder } = getPluginOptions();
cloudinary.config({
cloud_name: cloudName,
api_key: apiKey,
api_secret: apiSecret,
});
const uploadOptions = {
folder: uploadFolder,
overwrite,
public_id: publicId,
resource_type: 'auto',
timeout: FIVE_MINUTES,
};
let attempts = 1;
totalImages++;
while (true) {
try {
const result = await cloudinary.uploader.upload(url, uploadOptions);
uploadedImages++;
if (
uploadedImages == totalImages ||
uploadedImages % Math.ceil(totalImages / 100) == 0
)
reporter.info(
`[gatsby-transformer-cloudinary] Uploaded ${uploadedImages} of ${totalImages} images to Cloudinary. (${Math.round(
(100 * uploadedImages) / totalImages
)}%)`
);
return result;
} catch (error) {
const stringifiedError = JSON.stringify(error, null, 2);
if (attempts < 3) {
attempts += 1;
reporter.warn(
`An error occurred when uploading ${url} to Cloudinary: ${stringifiedError}`
);
} else {
reporter.panic(
`Unable to upload ${url} to Cloudinary after ${attempts} attempts: ${stringifiedError}`
);
}
}
}
};
exports.uploadImageNodeToCloudinary = async ({ node, reporter }) => {
const url = node.absolutePath;
const relativePathWithoutExtension = node.relativePath.replace(
/\.[^.]*$/,
''
);
const publicId = relativePathWithoutExtension;
const overwrite = getPluginOptions().overwriteExisting;
const result = await exports.uploadImageToCloudinary({
url,
publicId,
overwrite,
reporter,
});
return result;
};
function verifyRequiredOptions(reporter) {
const requiredOptions = ['apiKey', 'apiSecret', 'cloudName'];
const pluginOptions = getPluginOptions();
requiredOptions.forEach((optionKey) => {
if (pluginOptions[optionKey] == null) {
reporter.panic(
`[gatsby-transformer-cloudinary] "${optionKey}" is a required plugin option. You can add it to the options object for "gatsby-transformer-cloudinary" in your gatsby-config file.`
);
}
});
}

View File

@@ -0,0 +1,203 @@
const {
uploadImageToCloudinary,
uploadImageNodeToCloudinary,
} = require('./upload');
jest.mock('../options');
jest.mock('cloudinary');
const { getPluginOptions } = require('../options');
const cloudinary = require('cloudinary').v2;
const defaultPluginOptions = {
apiKey: 'apiKey',
apiSecret: 'apiSecret',
cloudName: 'cloudName',
};
describe('uploadImageToCloudinary', () => {
function getDefaultArgs(args) {
return {
url: 'url',
overwrite: 'overwrite',
publicId: 'publicId',
reporter: {
info: jest.fn(),
warn: jest.fn(),
panic: jest.fn(),
},
...args,
};
}
function getDefaultOptions(options) {
return {
cloudName: 'cloudName',
apiKey: 'apiKey',
apiSecret: 'apiSecret',
uploadFolder: 'uploadFolder',
createDerived: false,
...options,
};
}
it('configures cloudinary with the appropriate plugin options', async () => {
const cloudinaryConfig = jest.fn();
cloudinary.config = cloudinaryConfig;
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs();
await uploadImageToCloudinary(args);
const expected = {
cloud_name: options.cloudName,
api_key: options.apiKey,
api_secret: options.apiSecret,
};
expect(cloudinaryConfig).toHaveBeenCalledWith(expected);
});
it('overwrites when passed overwrite:true', async () => {
const cloudinaryUpload = jest.fn();
cloudinary.uploader.upload = cloudinaryUpload;
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs({ overwrite: true });
await uploadImageToCloudinary(args);
const expectedUrl = args.url;
const expectedOptions = {
folder: options.uploadFolder,
overwrite: true,
public_id: args.publicId,
resource_type: 'auto',
timeout: 5 * 60 * 1000,
};
expect(cloudinaryUpload).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
it('returns the result returned from the Cloudinary uploader', async () => {
const cloudinaryUpload = jest.fn();
const cloudinaryUploadResult = 'cloudinaryUploadResult';
cloudinaryUpload.mockReturnValue(cloudinaryUploadResult);
cloudinary.uploader.upload = cloudinaryUpload;
const options = getDefaultOptions();
getPluginOptions.mockReturnValue(options);
const args = getDefaultArgs();
expect(await uploadImageToCloudinary(args)).toEqual(cloudinaryUploadResult);
});
});
describe('uploadImageNodeToCloudinary', () => {
it("uses the image's relative path without the extension as the public ID", async () => {
const cloudinaryUpload = jest.fn();
cloudinary.uploader.upload = cloudinaryUpload;
const reporter = { info: jest.fn() };
const node = {
relativePath: 'folder-name/image.name.with.dots.jpg',
};
await uploadImageNodeToCloudinary({ node, reporter });
expect(cloudinaryUpload).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
public_id: 'folder-name/image.name.with.dots',
})
);
});
it('passes the overwrite setting from the plugin options', async () => {
const cloudinaryUpload = jest.fn();
cloudinary.uploader.upload = cloudinaryUpload;
const reporter = { info: jest.fn() };
const node = {
relativePath: 'relativePath.jpg',
};
const overwriteExisting = 'overwriteExistingDouble';
getPluginOptions.mockReturnValue({
...defaultPluginOptions,
overwriteExisting,
});
await uploadImageNodeToCloudinary({ node, reporter });
expect(cloudinaryUpload).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
overwrite: overwriteExisting,
})
);
});
it('requires the apiKey option', async () => {
const reporter = {
panic: jest.fn(() => {
throw Error();
}),
};
const node = { relativePath: 'relativePath.jpg' };
getPluginOptions.mockReturnValue({
...defaultPluginOptions,
apiKey: null,
});
try {
await uploadImageNodeToCloudinary({ node, reporter });
} catch {}
expect(reporter.panic).toHaveBeenCalledWith(
'[gatsby-transformer-cloudinary] "apiKey" is a required plugin option. You can add it to the options object for "gatsby-transformer-cloudinary" in your gatsby-config file.'
);
});
it('requires the apiSecret option', async () => {
const reporter = {
panic: jest.fn(() => {
throw Error();
}),
};
const node = { relativePath: 'relativePath.jpg' };
getPluginOptions.mockReturnValue({
...defaultPluginOptions,
apiSecret: null,
});
try {
await uploadImageNodeToCloudinary({ node, reporter });
} catch {}
expect(reporter.panic).toHaveBeenCalledWith(
'[gatsby-transformer-cloudinary] "apiSecret" is a required plugin option. You can add it to the options object for "gatsby-transformer-cloudinary" in your gatsby-config file.'
);
});
it('requires the cloudName option', async () => {
const reporter = {
panic: jest.fn(() => {
throw Error();
}),
};
const node = { relativePath: 'relativePath.jpg' };
getPluginOptions.mockReturnValue({
...defaultPluginOptions,
cloudName: null,
});
try {
await uploadImageNodeToCloudinary({ node, reporter });
} catch {}
expect(reporter.panic).toHaveBeenCalledWith(
'[gatsby-transformer-cloudinary] "cloudName" is a required plugin option. You can add it to the options object for "gatsby-transformer-cloudinary" in your gatsby-config file.'
);
});
});

View File

@@ -0,0 +1,28 @@
let options = {};
exports.initializaGlobalState = (_, pluginOptions) => {
// Make options available for createRemoteImageNode
// as it can be used outside of gatsby-node lifecycle hooks
Object.assign(options, pluginOptions);
};
exports.getPluginOptions = () => {
return options;
};
exports.getCoreSupportsOnPluginInit = () => {
let coreSupportsOnPluginInit = undefined;
try {
const { isGatsbyNodeLifecycleSupported } = require(`gatsby-plugin-utils`);
if (isGatsbyNodeLifecycleSupported(`onPluginInit`)) {
coreSupportsOnPluginInit = 'stable';
} else if (isGatsbyNodeLifecycleSupported(`unstable_onPluginInit`)) {
coreSupportsOnPluginInit = 'unstable';
}
} catch (error) {
console.error(
`[gatsby-transformer-cloudinary] Cannot check if Gatsby supports onPluginInit`
);
}
return coreSupportsOnPluginInit;
};

View File

@@ -0,0 +1,33 @@
{
"name": "gatsby-transformer-cloudinary",
"version": "4.5.0",
"description": "Transform local files into Cloudinary-managed assets for Gatsby sites.",
"main": "index.js",
"types": "index.d.ts",
"homepage": "https://gatsby-transformer-cloudinary.netlify.com/",
"repository": {
"type": "git",
"url": "https://github.com/cloudinary-devs/gatsby-transformer-cloudinary"
},
"keywords": [
"gatsby",
"gatsby-plugin",
"image",
"cloudinary"
],
"license": "MIT",
"dependencies": {
"axios": "~1.1.3",
"cloudinary": "^1.35.0",
"fast-json-stable-stringify": "2.1.0",
"gatsby-plugin-utils": "^4.0.0",
"probe-image-size": "7.2.3"
},
"scripts": {
"postversion": "cp ../README.md ./README.md && cp ../CHANGELOG.md ./CHANGELOG.md"
},
"peerDependencies": {
"gatsby": "^3.0.0 || ^4.0.0 || ^5.0.0",
"gatsby-plugin-image": "^1.14.0 || ^2.0.0 || ^3.0.0"
}
}