diff --git a/README.md b/README.md index 8789e1c..d2680fc 100644 --- a/README.md +++ b/README.md @@ -46,17 +46,17 @@ plugins: [ downloadImages: `boolean_value`, // Optional: Specify the content types from which you want the plugin to retrieve data. - contentTypes: [‘blog’,’author’] + contentTypes: [‘blog’,’author’], // This will fetch the data of the ‘blog’ and ‘author’ content types only. // Optional: Specify the content types that the plugin should exclude while fetching data of all content types. - excludeContentTypes: [‘home’,’about’] + excludeContentTypes: [‘home’,’about’], // This will fetch the data of all the available content types excluding the ‘home’ and ‘about’ content types. // Note: Only one of the above options should be used to fetch data. If you add both options to fetch all contentTypes and excludeContentTypes, than only one of the query gets executed. // Optional: Include the locales that you want the plugin to fetch data from. - locales: [‘en-us’,’fr-fr’] + locales: [‘en-us’,’fr-fr’], // In this case, the plugin will fetch only English (United States) and French (France) language data. // Optional: Specify the content types and locales of which you want the plugin to retrieve data. @@ -183,6 +183,82 @@ Remember that gatbsy-image doesn’t support GIF and SVG images. To use GIF image, Gatsby recommends to import the image directly. In SVG, creating multiple variants of the image doesn’t make sense because it is vector-based graphics that you can freely scale without losing quality. +### The new gatsby image plugin + +The gatsby-image plugin lets you add responsive images to your site. By using this plugin, you can format and produce images of various qualities and sizes. + +## Prerequisites + +To use this, you need to have the following plugins installed: + +- gatsby-plugin-image +- gatsby-plugin-sharp +- gatsby-transformer-sharp + +# Description + +Next step is to add an image to your page query and use the gatsbyImageData resolver to pass arguments that will configure your image. + +The gatsbyImageData resolver allows you to pass arguments to format and configure your images. +Using the Contentstack Image delivery APIs you can perform various operations on the images by passing the necessary parameters. + +Lets understand this with an example. +In the below example we have added several parameters to format the image. + +```query MyQuery { + allContentstackBlog { + edges { + node { + title + image { + title + gatsbyImageData( + layout: CONSTRAINED + crop: "100,100" + trim: "25,25,100,100" + backgroundColor:"cccccc" + pad: "25,25,25,25" + ) + } + } + } + } +}``` + +Lets understand some parameters that we defined: +layout: This defines the layout of the image, it can be CONSTRAINED, FIXED or FULL_WIDTH. +The crop, trim, backgroundColor and pad parameters configure the image according to the values inserted by the user. + +Note: To learn more about these parameters and other available options, read our detailed documentation on Contentstack Image delivery APIs. https://www.contentstack.com/docs/developers/apis/image-delivery-api/. + +This query below returns the URL for a 20px-wide image, to use as a blurred placeholder. +The image is downloaded and converted to a base64-encoded data URI. + +Here’s an example of the same: +```query MyQuery { + allContentstackBlog { + edges { + node { + title + image { + title + filename + url + gatsbyImageData( + layout: CONSTRAINED + placeholder: BLURRED + crop: "100,100" + trim: "25,25,100,100" + backgroundColor:"cccccc" + pad: "25,25,25,25" + ) + } + } + } + } +}``` + +For more information checkout gatsby's documentation on usage of the new image plugin. https://www.gatsbyjs.com/docs/how-to/plugins-and-themes/adding-gatsby-image-support/ [gatsby]: https://www.gatsbyjs.org/ [contentstack]: https://www.contentstack.com/ \ No newline at end of file diff --git a/extend-node-type.js b/extend-node-type.js new file mode 100644 index 0000000..8a3619e --- /dev/null +++ b/extend-node-type.js @@ -0,0 +1,151 @@ +'use strict'; + +var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); + +var _typeof = require("@babel/runtime/helpers/typeof"); + +var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator")); + +var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator")); + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || _typeof(obj) !== "object" && typeof obj !== "function") { return { "default": obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj["default"] = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _require = require('gatsby/graphql'), + GraphQLInt = _require.GraphQLInt, + GraphQLJSON = _require.GraphQLJSON, + GraphQLString = _require.GraphQLString; + +var _require2 = require('./gatsby-plugin-image'), + resolveGatsbyImageData = _require2.resolveGatsbyImageData; + +var _require3 = require('./utils'), + CODES = _require3.CODES; + +exports.setFieldsOnGraphQLNodeType = /*#__PURE__*/function () { + var _ref = (0, _asyncToGenerator2["default"])( /*#__PURE__*/_regenerator["default"].mark(function _callee3(_ref2, configOptions) { + var type, cache, reporter, typePrefix, getGatsbyImageData, gatsbyImageData; + return _regenerator["default"].wrap(function _callee3$(_context3) { + while (1) { + switch (_context3.prev = _context3.next) { + case 0: + type = _ref2.type, cache = _ref2.cache, reporter = _ref2.reporter; + typePrefix = configOptions.type_prefix || 'Contentstack'; + + if (!(type.name !== "".concat(typePrefix, "_assets"))) { + _context3.next = 4; + break; + } + + return _context3.abrupt("return", {}); + + case 4: + // gatsby-plugin-image + getGatsbyImageData = /*#__PURE__*/function () { + var _ref3 = (0, _asyncToGenerator2["default"])( /*#__PURE__*/_regenerator["default"].mark(function _callee2() { + var _yield$import, getGatsbyImageFieldConfig, fieldConfig; + + return _regenerator["default"].wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + _context2.prev = 0; + _context2.next = 3; + return Promise.resolve().then(function () { + return _interopRequireWildcard(require('gatsby-plugin-image/graphql-utils')); + }); + + case 3: + _yield$import = _context2.sent; + getGatsbyImageFieldConfig = _yield$import.getGatsbyImageFieldConfig; + fieldConfig = getGatsbyImageFieldConfig( /*#__PURE__*/function () { + var _ref4 = (0, _asyncToGenerator2["default"])( /*#__PURE__*/_regenerator["default"].mark(function _callee(image, options) { + return _regenerator["default"].wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + return _context.abrupt("return", resolveGatsbyImageData({ + image: image, + options: options, + cache: cache, + reporter: reporter + })); + + case 1: + case "end": + return _context.stop(); + } + } + }, _callee); + })); + + return function (_x3, _x4) { + return _ref4.apply(this, arguments); + }; + }(), { + fit: { + type: GraphQLString + }, + crop: { + type: GraphQLString + }, + trim: { + type: GraphQLString + }, + pad: { + type: GraphQLString + }, + quality: { + type: GraphQLInt, + defaultValue: 50 + } + }); + fieldConfig.type = GraphQLJSON; + return _context2.abrupt("return", fieldConfig); + + case 10: + _context2.prev = 10; + _context2.t0 = _context2["catch"](0); + reporter.panic({ + id: CODES.MissingDependencyError, + context: { + sourceMessage: "Gatsby plugin image is required. Please check https://github.com/contentstack/gatsby-source-contentstack#the-new-gatsby-image-plugin for more help." + }, + error: _context2.t0 + }); + + case 13: + case "end": + return _context2.stop(); + } + } + }, _callee2, null, [[0, 10]]); + })); + + return function getGatsbyImageData() { + return _ref3.apply(this, arguments); + }; + }(); + + _context3.next = 7; + return getGatsbyImageData(); + + case 7: + gatsbyImageData = _context3.sent; + return _context3.abrupt("return", { + gatsbyImageData: gatsbyImageData + }); + + case 9: + case "end": + return _context3.stop(); + } + } + }, _callee3); + })); + + return function (_x, _x2) { + return _ref.apply(this, arguments); + }; +}(); \ No newline at end of file diff --git a/gatsby-node.js b/gatsby-node.js index e8403eb..129917a 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -41,6 +41,9 @@ var _require3 = require('./fetch'), var downloadAssets = require('./download-assets'); +var _require4 = require('./extend-node-type'), + setFieldsOnGraphQLNodeType = _require4.setFieldsOnGraphQLNodeType; + var references = []; var groups = []; var fileFields = []; @@ -358,6 +361,8 @@ exports.sourceNodes = /*#__PURE__*/function () { }; }(); +exports.setFieldsOnGraphQLNodeType = setFieldsOnGraphQLNodeType; + exports.createResolvers = function (_ref6) { var createResolvers = _ref6.createResolvers; var resolvers = {}; @@ -450,12 +455,24 @@ var ERROR_MAP = (_ERROR_MAP = {}, (0, _defineProperty2["default"])(_ERROR_MAP, C }, level: "ERROR", type: "PLUGIN" +}), (0, _defineProperty2["default"])(_ERROR_MAP, CODES.ImageAPIError, { + text: function text(context) { + return context.sourceMessage; + }, + level: "ERROR", + type: "PLUGIN" +}), (0, _defineProperty2["default"])(_ERROR_MAP, CODES.MissingDependencyError, { + text: function text(context) { + return context.sourceMessage; + }, + level: "ERROR", + type: "PLUGIN" }), _ERROR_MAP); var coreSupportsOnPluginInit; try { - var _require4 = require('gatsby-plugin-utils'), - isGatsbyNodeLifecycleSupported = _require4.isGatsbyNodeLifecycleSupported; + var _require5 = require('gatsby-plugin-utils'), + isGatsbyNodeLifecycleSupported = _require5.isGatsbyNodeLifecycleSupported; if (isGatsbyNodeLifecycleSupported('onPluginInit')) { coreSupportsOnPluginInit = 'stable'; diff --git a/gatsby-plugin-image.js b/gatsby-plugin-image.js new file mode 100644 index 0000000..fa18137 --- /dev/null +++ b/gatsby-plugin-image.js @@ -0,0 +1,250 @@ +'use strict'; + +var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); + +var _typeof = require("@babel/runtime/helpers/typeof"); + +var _regenerator = _interopRequireDefault(require("@babel/runtime/regenerator")); + +var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray")); + +var _asyncToGenerator2 = _interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator")); + +var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || _typeof(obj) !== "object" && typeof obj !== "function") { return { "default": obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj["default"] = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) { symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); } keys.push.apply(keys, symbols); } return keys; } + +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { (0, _defineProperty2["default"])(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } + +var _require = require('gatsby-core-utils'), + fetchRemoteFile = _require.fetchRemoteFile; + +var readFile = require('fs').promises.readFile; + +var _require2 = require('./image-helper'), + createUrl = _require2.createUrl, + mimeTypeExtensions = _require2.mimeTypeExtensions, + validImageFormats = _require2.validImageFormats, + isImage = _require2.isImage; + +var _require3 = require('./utils'), + CODES = _require3.CODES; + +var unresolvedBase64Cache = {}; +var resolvedBase64Cache = {}; + +var getBase64Image = exports.getBase64Image = function (props, cache, reporter) { + var aspectRatio = props.aspectRatio; + var originalFormat = props.image.content_type.split('/')[1]; + var toFormat = props.options.toFormat; + + var imageOptions = _objectSpread(_objectSpread({}, props.options), {}, { + toFormat: toFormat, + width: 20, + height: Math.floor(20 * aspectRatio) + }); + + var csImageUrl = createUrl(props.baseUrl, imageOptions); + var resolvedUrl = resolvedBase64Cache[csImageUrl]; + + if (resolvedUrl) { + return resolvedUrl; + } + + var inflightUrl = unresolvedBase64Cache[csImageUrl]; + + if (inflightUrl) { + return inflightUrl; + } + + var loadImage = /*#__PURE__*/function () { + var _ref = (0, _asyncToGenerator2["default"])( /*#__PURE__*/_regenerator["default"].mark(function _callee() { + var content_type, extension, absolutePath, base64; + return _regenerator["default"].wrap(function _callee$(_context) { + while (1) { + switch (_context.prev = _context.next) { + case 0: + content_type = props.image.content_type; + extension = mimeTypeExtensions[content_type]; + _context.next = 4; + return fetchRemoteFile({ + url: csImageUrl, + cache: cache, + ext: extension + }); + + case 4: + absolutePath = _context.sent; + _context.next = 7; + return readFile(absolutePath); + + case 7: + base64 = _context.sent.toString('base64'); + return _context.abrupt("return", "data:image/".concat(toFormat || originalFormat, ";base64,").concat(base64)); + + case 9: + case "end": + return _context.stop(); + } + } + }, _callee); + })); + + return function loadImage() { + return _ref.apply(this, arguments); + }; + }(); + + var promise = loadImage(); + unresolvedBase64Cache[csImageUrl] = promise; + return promise.then(function (body) { + delete unresolvedBase64Cache[csImageUrl]; + resolvedBase64Cache[csImageUrl] = body; + })["catch"](function (error) { + reporter.panic({ + id: CODES.ImageAPIError, + context: { + sourceMessage: "Error occurred while fetching image. Please find the image url here: ".concat(props.baseUrl) + }, + error: error + }); + }); +}; + +function getBasicImageProps(image, args) { + var aspectRatio; + + if (args.width && args.height) { + aspectRatio = args.width / args.height; + } else { + aspectRatio = image.dimension.width / image.dimension.height; + } + + return { + baseUrl: image.url, + contentType: image.content_type, + aspectRatio: aspectRatio, + width: image.dimension.width, + height: image.dimension.height + }; +} // Generate image source data for gatsby-plugin-image + + +function generateImageSource(filename, width, height, toFormat, _fit, imageTransformOptions) { + var quality = imageTransformOptions.quality, + crop = imageTransformOptions.crop, + backgroundColor = imageTransformOptions.backgroundColor, + fit = imageTransformOptions.fit, + trim = imageTransformOptions.trim, + pad = imageTransformOptions.pad; + + if (!validImageFormats.includes(toFormat)) { + console.warn("[gatsby-source-contentstack] Invalid image format \"".concat(toFormat, "\". Supported types are ").concat(validImageFormats.join(', '))); + return; + } + + var src = createUrl(filename, { + width: width, + height: height, + toFormat: toFormat, + fit: fit, + background: backgroundColor === null || backgroundColor === void 0 ? void 0 : backgroundColor.replace('#', 'rgb:'), + quality: quality, + crop: crop, + trim: trim, + pad: pad + }); + return { + width: width, + height: height, + format: toFormat, + src: src + }; +} + +exports.resolveGatsbyImageData = /*#__PURE__*/function () { + var _ref2 = (0, _asyncToGenerator2["default"])( /*#__PURE__*/_regenerator["default"].mark(function _callee2(_ref3) { + var image, options, cache, reporter, _yield$import, generateImageData, _getBasicImageProps, baseUrl, contentType, width, height, _contentType$split, _contentType$split2, format, imageProps, placeholderDataURI; + + return _regenerator["default"].wrap(function _callee2$(_context2) { + while (1) { + switch (_context2.prev = _context2.next) { + case 0: + image = _ref3.image, options = _ref3.options, cache = _ref3.cache, reporter = _ref3.reporter; + + if (isImage(image)) { + _context2.next = 3; + break; + } + + return _context2.abrupt("return", null); + + case 3: + _context2.next = 5; + return Promise.resolve().then(function () { + return _interopRequireWildcard(require('gatsby-plugin-image')); + }); + + case 5: + _yield$import = _context2.sent; + generateImageData = _yield$import.generateImageData; + _getBasicImageProps = getBasicImageProps(image, options), baseUrl = _getBasicImageProps.baseUrl, contentType = _getBasicImageProps.contentType, width = _getBasicImageProps.width, height = _getBasicImageProps.height; + _contentType$split = contentType.split('/'), _contentType$split2 = (0, _slicedToArray2["default"])(_contentType$split, 2), format = _contentType$split2[1]; + + if (format === 'jpeg') { + format = 'jpg'; + } + + imageProps = generateImageData(_objectSpread(_objectSpread({}, options), {}, { + pluginName: 'gatsby-source-contentstack', + sourceMetadata: { + width: width, + height: height, + format: format + }, + filename: baseUrl, + generateImageSource: generateImageSource, + options: options + })); + placeholderDataURI = null; + + if (!(options.placeholder === 'blurred')) { + _context2.next = 16; + break; + } + + _context2.next = 15; + return getBase64Image({ + baseUrl: baseUrl, + image: image, + options: options + }, cache, reporter); + + case 15: + placeholderDataURI = _context2.sent; + + case 16: + if (placeholderDataURI) { + imageProps.placeholder = { + fallback: placeholderDataURI + }; + } + + return _context2.abrupt("return", imageProps); + + case 18: + case "end": + return _context2.stop(); + } + } + }, _callee2); + })); + + return function (_x) { + return _ref2.apply(this, arguments); + }; +}(); \ No newline at end of file diff --git a/image-helper.js b/image-helper.js new file mode 100644 index 0000000..a8f9ab1 --- /dev/null +++ b/image-helper.js @@ -0,0 +1,51 @@ +'use strict'; + +var _require = require('url'), + URLSearchParams = _require.URLSearchParams; // Determine the proper file extension based on mime type + + +var mimeTypeExtensions = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/gif': '.gif', + 'image/png': '.png', + 'image/webp': '.webp' +}; // Supported image formats by contentstack image API + +var validImageFormats = ['jpg', 'png', 'webp', 'gif']; + +var isImage = function isImage(image) { + return !!mimeTypeExtensions[image === null || image === void 0 ? void 0 : image.content_type]; +}; // Creates a Contentstack image url + + +var createUrl = function createUrl(imgUrl) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + var queryParams = { + width: options.width, + height: options.height, + format: options.toFormat, + quality: options.quality, + crop: options.crop, + fit: options.fit, + trim: options.trim, + pad: options.pad, + 'bg-color': options.background + }; + var searchParams = new URLSearchParams(); + + for (var key in queryParams) { + if (typeof queryParams[key] !== 'undefined') { + var _queryParams$key; + + searchParams.append(key, (_queryParams$key = queryParams[key]) !== null && _queryParams$key !== void 0 ? _queryParams$key : ''); + } + } + + return "".concat(imgUrl, "?").concat(searchParams.toString()); +}; + +exports.mimeTypeExtensions = mimeTypeExtensions; +exports.validImageFormats = validImageFormats; +exports.isImage = isImage; +exports.createUrl = createUrl; \ No newline at end of file diff --git a/package.json b/package.json index 3eb665d..4b22cd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gatsby-source-contentstack", - "version": "3.1.0", + "version": "3.1.1", "description": "Gatsby source plugin for building websites using Contentstack as a data source", "scripts": { "prepublish": "npm run build", @@ -26,6 +26,7 @@ }, "license": "MIT", "dependencies": { + "gatsby-core-utils": "^3.3.0", "gatsby-source-filesystem": "^3.13.0", "node-fetch": "2.1.2", "progress": "^2.0.3", @@ -56,7 +57,8 @@ "semantic-release": "^17.4.4" }, "peerDependencies": { - "gatsby": "^3.0.0" + "gatsby": "^3.0.0", + "gatsby-plugin-image": "^2.0.0-next" }, "husky": { "hooks": { diff --git a/schemes.js b/schemes.js new file mode 100644 index 0000000..9cd67ce --- /dev/null +++ b/schemes.js @@ -0,0 +1,71 @@ +'use strict'; + +var _require = require('gatsby/graphql'), + GraphQLEnumType = _require.GraphQLEnumType; + +exports.ImageResizingBehavior = new GraphQLEnumType({ + name: 'ImageResizingBehavior', + values: { + NO_CHANGE: { + value: '' + }, + PAD: { + value: 'pad', + description: 'Same as default resizing, but adds padding so that generated image has the specified dimensions.' + }, + CROP: { + value: 'crop', + description: 'Crop a part of the original image to match the specified size.' + }, + FILL: { + value: 'fill', + description: 'Crop the image to the specified dimensions, if the original image is smaller than these dimensions, then the image will be upscaled.' + }, + THUMB: { + value: 'thumb', + description: 'When used in association with the f parameter below, creates a thumbnail from the image based on a focus area.' + }, + SCALE: { + value: 'scale', + description: 'Scale the image regardless of the original aspect ratio.' + } + } +}); +exports.ImageCropFocusType = new GraphQLEnumType({ + name: 'ContentstackImageCropFocus', + values: { + TOP: { + value: "top" + }, + TOP_LEFT: { + value: "top_left" + }, + TOP_RIGHT: { + value: "top_right" + }, + BOTTOM: { + value: "bottom" + }, + BOTTOM_RIGHT: { + value: "bottom_left" + }, + BOTTOM_LEFT: { + value: "bottom_right" + }, + RIGHT: { + value: "right" + }, + LEFT: { + value: "left" + }, + FACE: { + value: "face" + }, + FACES: { + value: "faces" + }, + CENTER: { + value: "center" + } + } +}); \ No newline at end of file diff --git a/src/extend-node-type.js b/src/extend-node-type.js new file mode 100644 index 0000000..d8cc5db --- /dev/null +++ b/src/extend-node-type.js @@ -0,0 +1,55 @@ +'use strict'; + +const { GraphQLInt, GraphQLJSON, GraphQLString } = require('gatsby/graphql'); + +const { resolveGatsbyImageData } = require('./gatsby-plugin-image'); +const { CODES } = require('./utils'); + +exports.setFieldsOnGraphQLNodeType = async ({ type, cache, reporter }, configOptions) => { + const typePrefix = configOptions.type_prefix || 'Contentstack'; + if (type.name !== `${typePrefix}_assets`) { + return {}; + } + + // gatsby-plugin-image + const getGatsbyImageData = async () => { + try { + const { getGatsbyImageFieldConfig } = await import('gatsby-plugin-image/graphql-utils'); + + const fieldConfig = getGatsbyImageFieldConfig( + async (image, options) => resolveGatsbyImageData({ image, options, cache, reporter }), + { + fit: { + type: GraphQLString, + }, + crop: { + type: GraphQLString, + }, + trim: { + type: GraphQLString, + }, + pad: { + type: GraphQLString, + }, + quality: { + type: GraphQLInt, + defaultValue: 50, + }, + } + ); + + fieldConfig.type = GraphQLJSON; + + return fieldConfig; + } catch (error) { + reporter.panic({ + id: CODES.MissingDependencyError, + context: { sourceMessage: `Gatsby plugin image is required. Please check https://github.com/contentstack/gatsby-source-contentstack#the-new-gatsby-image-plugin for more help.` }, + error + }); + } + }; + + const gatsbyImageData = await getGatsbyImageData(); + return { gatsbyImageData }; +}; \ No newline at end of file diff --git a/src/gatsby-node.js b/src/gatsby-node.js index b201ee5..9be31be 100644 --- a/src/gatsby-node.js +++ b/src/gatsby-node.js @@ -8,6 +8,7 @@ const { normalizeEntry, sanitizeEntry, processContentType, processEntry, process const { checkIfUnsupportedFormat, SUPPORTED_FILES_COUNT, IMAGE_REGEXP, CODES, getContentTypeOption }=require('./utils'); const { fetchData, fetchContentTypes } = require('./fetch'); const downloadAssets = require('./download-assets'); +const { setFieldsOnGraphQLNodeType } = require('./extend-node-type'); let references = []; let groups = []; @@ -262,6 +263,7 @@ exports.sourceNodes = async ({ cache, actions, getNode, getNodes, createNodeId, }); }; +exports.setFieldsOnGraphQLNodeType = setFieldsOnGraphQLNodeType; exports.createResolvers = ({ createResolvers }) => { const resolvers = {}; @@ -365,7 +367,17 @@ const ERROR_MAP = { text: context => context.sourceMessage, level: `ERROR`, type: `PLUGIN` - } + }, + [CODES.ImageAPIError]: { + text: context => context.sourceMessage, + level: `ERROR`, + type: `PLUGIN` + }, + [CODES.MissingDependencyError]: { + text: context => context.sourceMessage, + level: `ERROR`, + type: `PLUGIN`, + }, }; let coreSupportsOnPluginInit; diff --git a/src/gatsby-plugin-image.js b/src/gatsby-plugin-image.js new file mode 100644 index 0000000..148b0be --- /dev/null +++ b/src/gatsby-plugin-image.js @@ -0,0 +1,130 @@ +'use strict'; + +const { fetchRemoteFile } = require('gatsby-core-utils'); +const { readFile } = require('fs').promises; + +const { createUrl, mimeTypeExtensions, validImageFormats, isImage } = require('./image-helper'); +const { CODES } = require('./utils'); + +const unresolvedBase64Cache = {}; +const resolvedBase64Cache = {}; + +const getBase64Image = exports.getBase64Image = (props, cache, reporter) => { + const { aspectRatio } = props; + const originalFormat = props.image.content_type.split('/')[1]; + const toFormat = props.options.toFormat; + const imageOptions = { + ...props.options, + toFormat, + width: 20, + height: Math.floor(20 * aspectRatio), + }; + + const csImageUrl = createUrl(props.baseUrl, imageOptions); + + const resolvedUrl = resolvedBase64Cache[csImageUrl]; + if (resolvedUrl) { + return resolvedUrl; + } + + const inflightUrl = unresolvedBase64Cache[csImageUrl]; + if (inflightUrl) { + return inflightUrl; + } + + const loadImage = async () => { + const { content_type } = props.image; + const extension = mimeTypeExtensions[content_type]; + const absolutePath = await fetchRemoteFile({ + url: csImageUrl, + cache, + ext: extension, + }); + const base64 = (await readFile(absolutePath)).toString('base64'); + return `data:image/${toFormat || originalFormat};base64,${base64}`; + }; + + const promise = loadImage(); + unresolvedBase64Cache[csImageUrl] = promise; + + return promise.then(body => { + delete unresolvedBase64Cache[csImageUrl]; + resolvedBase64Cache[csImageUrl] = body; + }).catch(error => { + reporter.panic({ + id: CODES.ImageAPIError, + context: { + sourceMessage: `Error occurred while fetching image. Please find the image url here: ${props.baseUrl}`, + }, + error, + }); + }); +}; + +function getBasicImageProps(image, args) { + let aspectRatio; + if (args.width && args.height) { + aspectRatio = args.width / args.height; + } else { + aspectRatio = image.dimension.width / image.dimension.height; + } + + return { + baseUrl: image.url, + contentType: image.content_type, + aspectRatio: aspectRatio, + width: image.dimension.width, + height: image.dimension.height + }; +} + +// Generate image source data for gatsby-plugin-image +function generateImageSource(filename, width, height, toFormat, _fit, imageTransformOptions) { + const { quality, crop, backgroundColor, fit, trim, pad } = imageTransformOptions; + + if (!validImageFormats.includes(toFormat)) { + console.warn(`[gatsby-source-contentstack] Invalid image format "${toFormat}". Supported types are ${validImageFormats.join(', ')}`); + return; + } + + const src = createUrl(filename, { + width, height, toFormat, fit, + background: backgroundColor?.replace('#', 'rgb:'), + quality, crop, trim, pad, + }); + + return { width, height, format: toFormat, src }; +} + +exports.resolveGatsbyImageData = async ({ image, options, cache, reporter }) => { + if (!isImage(image)) return null; + + const { generateImageData } = await import('gatsby-plugin-image'); + const { baseUrl, contentType, width, height } = getBasicImageProps(image, options); + + let [, format] = contentType.split('/'); + if (format === 'jpeg') { + format = 'jpg'; + } + + const imageProps = generateImageData({ + ...options, + pluginName: 'gatsby-source-contentstack', + sourceMetadata: { width, height, format }, + filename: baseUrl, + generateImageSource, + options, + }); + + let placeholderDataURI = null; + + if (options.placeholder === 'blurred') { + placeholderDataURI = await getBase64Image({ baseUrl, image, options }, cache, reporter); + } + + if (placeholderDataURI) { + imageProps.placeholder = { fallback: placeholderDataURI }; + } + + return imageProps; +} \ No newline at end of file diff --git a/src/image-helper.js b/src/image-helper.js new file mode 100644 index 0000000..b4306dd --- /dev/null +++ b/src/image-helper.js @@ -0,0 +1,45 @@ +'use strict'; + +const { URLSearchParams } = require('url'); + +// Determine the proper file extension based on mime type +const mimeTypeExtensions = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/gif': '.gif', + 'image/png': '.png', + 'image/webp': '.webp', +}; + +// Supported image formats by contentstack image API +const validImageFormats = ['jpg', 'png', 'webp', 'gif']; + +const isImage = image => !!mimeTypeExtensions[image?.content_type]; + +// Creates a Contentstack image url +const createUrl = (imgUrl, options = {}) => { + const queryParams = { + width: options.width, + height: options.height, + format: options.toFormat, + quality: options.quality, + crop: options.crop, + fit: options.fit, + trim: options.trim, + pad: options.pad, + 'bg-color': options.background, + }; + + const searchParams = new URLSearchParams(); + for (const key in queryParams) { + if (typeof queryParams[key] !== 'undefined') { + searchParams.append(key, queryParams[key] ?? ''); + } + } + return `${imgUrl}?${searchParams.toString()}`; +}; + +exports.mimeTypeExtensions = mimeTypeExtensions; +exports.validImageFormats = validImageFormats; +exports.isImage = isImage; +exports.createUrl = createUrl; \ No newline at end of file diff --git a/src/utils.js b/src/utils.js index ea26fa0..5c18e7d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -44,7 +44,9 @@ exports.IMAGE_REGEXP = new RegExp('https://(stag-images|(eu-)?images).(blz-)?con exports.CODES = { SyncError: '10001', - APIError: '10002' + APIError: '10002', + ImageAPIError: '10003', + MissingDependencyError: '10004', }; exports.getContentTypeOption = configOptions => { diff --git a/utils.js b/utils.js index e2b9c74..de3a6cd 100644 --- a/utils.js +++ b/utils.js @@ -46,7 +46,9 @@ exports.SUPPORTED_FILES_COUNT = 'SUPPORTED_FILES_COUNT'; exports.IMAGE_REGEXP = new RegExp('https://(stag-images|(eu-)?images).(blz-)?contentstack.(io|com)/v3/assets/'); exports.CODES = { SyncError: '10001', - APIError: '10002' + APIError: '10002', + ImageAPIError: '10003', + MissingDependencyError: '10004' }; exports.getContentTypeOption = function (configOptions) {