diff --git a/packages/gatsby-source-contentful/package.json b/packages/gatsby-source-contentful/package.json index 5b40c59e61f2e..e97aa714cac70 100644 --- a/packages/gatsby-source-contentful/package.json +++ b/packages/gatsby-source-contentful/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@babel/runtime": "^7.10.3", + "@contentful/rich-text-react-renderer": "^14.1.1", "@contentful/rich-text-types": "^13.4.0", "@hapi/joi": "^15.1.1", "axios": "^0.19.2", diff --git a/packages/gatsby-source-contentful/src/__tests__/plugin-options.js b/packages/gatsby-source-contentful/src/__tests__/plugin-options.js index c54a9cc35df1a..8c5e0efc61f41 100644 --- a/packages/gatsby-source-contentful/src/__tests__/plugin-options.js +++ b/packages/gatsby-source-contentful/src/__tests__/plugin-options.js @@ -171,7 +171,6 @@ describe(`Options validation`, () => { downloadLocal: 5, useNameForId: 5, pageLimit: `fifty`, - richText: true, } ) @@ -204,9 +203,6 @@ describe(`Options validation`, () => { expect(reporter.panic).toBeCalledWith( expect.stringContaining(`"pageLimit" must be a number`) ) - expect(reporter.panic).toBeCalledWith( - expect.stringContaining(`"richText" must be an object`) - ) }) it(`Fails with undefined option keys`, () => { diff --git a/packages/gatsby-source-contentful/src/__tests__/rich-text.js b/packages/gatsby-source-contentful/src/__tests__/rich-text.js deleted file mode 100644 index 295566b0bc0fd..0000000000000 --- a/packages/gatsby-source-contentful/src/__tests__/rich-text.js +++ /dev/null @@ -1,355 +0,0 @@ -const { getNormalizedRichTextField } = require(`../rich-text`) - -const entryFactory = () => { - return { - sys: { - id: `abc123`, - contentType: { sys: { id: `article` } }, - type: `Entry`, - }, - fields: { - title: { en: `Title`, de: `Titel` }, - relatedArticle: { - en: { - sys: { - contentType: { sys: { id: `article` } }, - type: `Entry`, - }, - fields: { - title: { en: `Title two`, de: `Titel zwei` }, - }, - }, - }, - }, - } -} - -const assetFactory = () => { - return { - sys: { - type: `Asset`, - }, - fields: { - file: { - en: { - url: `//images.ctfassets.net/asset.jpg`, - }, - }, - }, - } -} - -describe(`getNormalizedRichTextField()`, () => { - let contentTypesById - let currentLocale - let defaultLocale - let getField - - beforeEach(() => { - contentTypesById = new Map() - contentTypesById.set(`article`, { - sys: { id: `article` }, - fields: [ - { id: `title`, localized: true }, - { id: `relatedArticle`, localized: false }, - ], - }) - currentLocale = `en` - defaultLocale = `en` - getField = field => field[currentLocale] - }) - - describe(`when the rich-text object has no entry references`, () => { - it(`returns the object as-is`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `text`, - data: {}, - content: `This is a test`, - }, - ], - } - expect( - getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }) - ).toEqual(field) - }) - }) - - describe(`when a rich-text node contains an entry reference`, () => { - describe(`a localized field`, () => { - describe(`when the current locale is \`en\``, () => { - beforeEach(() => (currentLocale = `en`)) - - it(`resolves the locale for the field`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-entry-inline`, - data: { target: entryFactory() }, - }, - ], - } - - const expectedTitle = `Title` - const actualTitle = getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }).content[0].data.target.fields.title - - expect(actualTitle).toBe(expectedTitle) - }) - }) - - describe(`when the current locale is \`de\``, () => { - beforeEach(() => (currentLocale = `de`)) - - it(`resolves the locale for the field`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-entry-inline`, - data: { target: entryFactory() }, - }, - ], - } - - const expectedTitle = `Titel` - const actualTitle = getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }).content[0].data.target.fields.title - - expect(actualTitle).toBe(expectedTitle) - }) - }) - }) - - describe(`a nested localized field`, () => { - beforeEach(() => { - currentLocale = `de` - }) - - it(`resolves the locale for the nested field`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-entry-inline`, - data: { target: entryFactory() }, - }, - ], - } - - const expectedTitle = `Titel zwei` - const actualTitle = getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }).content[0].data.target.fields.relatedArticle.fields.title - - expect(actualTitle).toBe(expectedTitle) - }) - }) - }) - - describe(`when a rich-text node contains an asset reference`, () => { - describe(`when the current locale is \`en\``, () => { - beforeEach(() => (currentLocale = `en`)) - - it(`resolves the locale for the field`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-asset-block`, - data: { target: assetFactory() }, - }, - ], - } - - const expectedURL = `//images.ctfassets.net/asset.jpg` - const actualURL = getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }).content[0].data.target.fields.file.url - - expect(actualURL).toBe(expectedURL) - }) - }) - }) - - describe(`when a referenced entry contains an asset field`, () => { - describe(`when the current locale is \`en\``, () => { - beforeEach(() => { - contentTypesById.get(`article`).fields.push({ - id: `assetReference`, - localized: true, - }) - currentLocale = `en` - }) - - it(`resolves the locale for the asset's own fields`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-entry-block`, - data: { target: entryFactory() }, - }, - ], - } - - field.content[0].data.target.fields.assetReference = { - en: assetFactory(), - } - - const expectedURL = `//images.ctfassets.net/asset.jpg` - const actualURL = getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }).content[0].data.target.fields.assetReference.fields.file.url - - expect(actualURL).toBe(expectedURL) - }) - }) - }) - - describe(`when an entry/asset reference field is an array`, () => { - beforeEach(() => { - contentTypesById.get(`article`).fields.push({ - id: `relatedArticles`, - localized: false, - }) - contentTypesById.get(`article`).fields.push({ - id: `relatedAssets`, - localized: false, - }) - }) - - it(`resolves the locales of each entry in the array`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-entry-block`, - data: { target: entryFactory() }, - }, - ], - } - - const relatedArticle = entryFactory() - relatedArticle.fields.title = { en: `Related article #1` } - - field.content[0].data.target.fields.relatedArticles = { - en: [relatedArticle], - } - - const expectedTitle = `Related article #1` - const actualTitle = getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }).content[0].data.target.fields.relatedArticles[0].fields.title - - expect(actualTitle).toBe(expectedTitle) - }) - - it(`resolves the locales of each asset in the array`, () => { - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-entry-block`, - data: { target: entryFactory() }, - }, - ], - } - - const relatedAsset = assetFactory() - relatedAsset.fields.file = { - en: { - url: `//images.ctfassets.net/related-asset.jpg`, - }, - } - - field.content[0].data.target.fields.relatedAssets = { - en: [relatedAsset], - } - - const expectedURL = `//images.ctfassets.net/related-asset.jpg` - const actualURL = getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }).content[0].data.target.fields.relatedAssets[0].fields.file.url - - expect(actualURL).toBe(expectedURL) - }) - }) - - describe(`circular references`, () => { - it(`prevents infinite loops when two entries reference each other`, () => { - const entry1 = entryFactory() - entry1.sys.id = `entry1-id` - const entry2 = entryFactory() - entry2.sys.id = `entry2-id` - - entry2.fields.title = { - en: `Related article`, - de: `German for "related article" ;)`, - } - - // Link the two to each other - entry1.fields.relatedArticle.en = entry2 - entry2.fields.relatedArticle.en = entry1 - - const field = { - nodeType: `document`, - data: {}, - content: [ - { - nodeType: `embedded-entry-inline`, - data: { target: entry1 }, - }, - ], - } - - expect(() => { - getNormalizedRichTextField({ - field, - contentTypesById, - getField, - defaultLocale, - }) - }).not.toThrowError() - }) - }) -}) diff --git a/packages/gatsby-source-contentful/src/extend-node-type.js b/packages/gatsby-source-contentful/src/extend-node-type.js index e30b3ffd240bc..b3f95c39be495 100644 --- a/packages/gatsby-source-contentful/src/extend-node-type.js +++ b/packages/gatsby-source-contentful/src/extend-node-type.js @@ -541,7 +541,7 @@ const fluidNodeType = ({ name, getTracedSVG }) => { } } -exports.extendNodeType = ({ type, store }) => { +exports.extendNodeType = ({ type, store, cache }) => { if (type.name.match(/contentful.*RichTextNode/)) { return { nodeType: { @@ -552,6 +552,71 @@ exports.extendNodeType = ({ type, store }) => { type: GraphQLString, deprecationReason: `This field is deprecated, please use 'raw' instead. @todo add link to migration steps.`, }, + references: { + type: [`ContentfulReference`], + async resolve(source, args, context, info) { + const parent = await context.nodeModel.findRootNodeAncestor(source) + + const rawReferences = { Entry: new Set(), Asset: new Set() } + + // Locate all Contentful Links within the rich text data + const traverse = obj => { + for (let k in obj) { + const value = obj[k] + if (value && value.sys && value.sys.type === `Link`) { + rawReferences[value.sys.linkType].add(value.sys.contentful_id) + } else if (value && typeof value === `object`) { + traverse(value) + } + } + } + + traverse(JSON.parse(source.raw)) + + if (!rawReferences.Entry.length && !rawReferences.Asset.length) { + return [] + } + + // Query for referenced nodes + const resultEntries = await context.nodeModel.runQuery({ + query: { + filter: { + contentful_id: { in: rawReferences.Entry }, + }, + }, + type: `ContentfulEntry`, + }) + + const resultAssets = await context.nodeModel.runQuery({ + query: { + filter: { + contentful_id: { in: rawReferences.Asset }, + }, + }, + type: `ContentfulAsset`, + }) + + // Localize results + const nodeLocale = parent.node_locale + + const findForLocaleWithFallback = (nodeList = [], referenceId) => + nodeList.find( + result => + result.contentful_id === referenceId && + result.node_locale === nodeLocale + ) + + const localizedEntryReferences = rawReferences.Entry.map( + referenceId => findForLocaleWithFallback(resultEntries, referenceId) + ) + + const localizedAssetReferences = rawReferences.Asset.map( + referenceId => findForLocaleWithFallback(resultAssets, referenceId) + ) + + return [...localizedEntryReferences, ...localizedAssetReferences] + }, + }, } } diff --git a/packages/gatsby-source-contentful/src/gatsby-node.js b/packages/gatsby-source-contentful/src/gatsby-node.js index 5e1f01c7c23b0..bcf1aab9abc3f 100644 --- a/packages/gatsby-source-contentful/src/gatsby-node.js +++ b/packages/gatsby-source-contentful/src/gatsby-node.js @@ -47,10 +47,11 @@ exports.sourceNodes = async ( cache, getCache, reporter, + schema, }, pluginOptions ) => { - const { createNode, deleteNode, touchNode } = actions + const { createNode, deleteNode, touchNode, createTypes } = actions const client = createClient({ space: `none`, accessToken: `fake-access-token`, @@ -118,6 +119,43 @@ exports.sourceNodes = async ( pluginConfig, }) + createTypes(` + interface ContentfulEntry @nodeInterface { + contentful_id: String! + id: ID! + } +`) + + createTypes(` + interface ContentfulReference { + contentful_id: String! + id: ID! + } +`) + + createTypes( + schema.buildObjectType({ + name: `ContentfulAsset`, + fields: { + contentful_id: { type: `String!` }, + id: { type: `ID!` }, + }, + interfaces: [`ContentfulReference`, `Node`], + }) + ) + + const gqlTypes = contentTypeItems.map(contentTypeItem => + schema.buildObjectType({ + name: _.upperFirst(_.camelCase(`Contentful ${contentTypeItem.name}`)), + fields: { + contentful_id: { type: `String!` }, + id: { type: `ID!` }, + }, + interfaces: [`ContentfulReference`, `ContentfulEntry`, `Node`], + }) + ) + createTypes(gqlTypes) + console.time(`Process Contentful data`) // Remove deleted entries and assets from cached sync data set @@ -333,7 +371,6 @@ exports.sourceNodes = async ( locales, space, useNameForId: pluginConfig.get(`useNameForId`), - richTextOptions: pluginConfig.get(`richText`), }) ) } diff --git a/packages/gatsby-source-contentful/src/normalize.js b/packages/gatsby-source-contentful/src/normalize.js index d668a7a12aa91..1e59f35d305d1 100644 --- a/packages/gatsby-source-contentful/src/normalize.js +++ b/packages/gatsby-source-contentful/src/normalize.js @@ -3,7 +3,6 @@ const stringify = require(`json-stringify-safe`) const { createContentDigest } = require(`gatsby-core-utils`) const digest = str => createContentDigest(str) -const { getNormalizedRichTextField } = require(`./rich-text`) const typePrefix = `Contentful` const makeTypeName = type => _.upperFirst(_.camelCase(`${typePrefix} ${type}`)) @@ -324,7 +323,6 @@ exports.createNodesForContentType = ({ locales, space, useNameForId, - richTextOptions, }) => { // Establish identifier for content type // Use `name` if specified, otherwise, use internal id (usually a natural-language constant, @@ -373,25 +371,6 @@ exports.createNodesForContentType = ({ ? getField(v) : v[defaultLocale] - if ( - fieldProps.type === `RichText` && - richTextOptions && - richTextOptions.resolveFieldLocales - ) { - const contentTypesById = new Map() - contentTypeItems.forEach(contentTypeItem => - contentTypesById.set(contentTypeItem.sys.id, contentTypeItem) - ) - - return getNormalizedRichTextField({ - field: localizedField, - fieldProps, - contentTypesById, - getField, - defaultLocale, - }) - } - return localizedField }) diff --git a/packages/gatsby-source-contentful/src/plugin-options.js b/packages/gatsby-source-contentful/src/plugin-options.js index 56d9d2e0346a3..104ad93c9fc76 100644 --- a/packages/gatsby-source-contentful/src/plugin-options.js +++ b/packages/gatsby-source-contentful/src/plugin-options.js @@ -44,11 +44,6 @@ const optionsSchema = Joi.object().keys({ useNameForId: Joi.boolean(), // default plugins passed by gatsby plugins: Joi.array(), - richText: Joi.object() - .keys({ - resolveFieldLocales: Joi.boolean(), - }) - .default({}), }) const maskedFields = [`accessToken`, `spaceId`] diff --git a/packages/gatsby-source-contentful/src/rich-text.js b/packages/gatsby-source-contentful/src/rich-text.js index 77636c29599ca..0d1eac3db4f58 100644 --- a/packages/gatsby-source-contentful/src/rich-text.js +++ b/packages/gatsby-source-contentful/src/rich-text.js @@ -1,185 +1,70 @@ -const _ = require(`lodash`) -const { BLOCKS, INLINES } = require(`@contentful/rich-text-types`) - -const isEntryReferenceNode = node => - [ - BLOCKS.EMBEDDED_ENTRY, - INLINES.ENTRY_HYPERLINK, - INLINES.EMBEDDED_ENTRY, - ].indexOf(node.nodeType) >= 0 - -const isAssetReferenceNode = node => - [BLOCKS.EMBEDDED_ASSET, INLINES.ASSET_HYPERLINK].indexOf(node.nodeType) >= 0 - -const isEntryReferenceField = field => _.get(field, `sys.type`) === `Entry` -const isAssetReferenceField = field => _.get(field, `sys.type`) === `Asset` - -const getFieldProps = (contentType, fieldName) => - contentType.fields.find(({ id }) => id === fieldName) - -const getAssetWithFieldLocalesResolved = ({ asset, getField }) => { - return { - ...asset, - fields: _.mapValues(asset.fields, getField), - } -} - -const getFieldWithLocaleResolved = ({ - field, - contentTypesById, - getField, - defaultLocale, - resolvedEntryIDs, -}) => { - // If the field is itself a reference to another entry, recursively resolve - // that entry's field locales too. - if (isEntryReferenceField(field)) { - const key = `${field.sys.id}___${field.sys.type}` - if (resolvedEntryIDs.has(key)) { - return field +// import { fixId } from "./normalize" +import { documentToReactComponents } from "@contentful/rich-text-react-renderer" +import resolveResponse from "contentful-resolve-response" + +// We need the original id from contentful as id attribute +// This code can be simplified as soon we properly replicate +// Contentful's sys object +const prepareIdsForResolving = obj => { + for (let k in obj) { + const value = obj[k] + if ( + value && + value.sys && + value.sys.type === `Link` && + value.sys.contentful_id + ) { + value.sys.id = value.sys.contentful_id + delete value.sys.contentful_id + } else if (value && typeof value === `object`) { + prepareIdsForResolving(value) } - - return getEntryWithFieldLocalesResolved({ - entry: field, - contentTypesById, - getField, - defaultLocale, - resolvedEntryIDs: resolvedEntryIDs.add(key), - }) } - - if (isAssetReferenceField(field)) { - return getAssetWithFieldLocalesResolved({ - asset: field, - getField, - }) - } - - if (Array.isArray(field)) { - return field.map(fieldItem => - getFieldWithLocaleResolved({ - field: fieldItem, - contentTypesById, - getField, - defaultLocale, - resolvedEntryIDs, - }) - ) - } - - return field } -const getEntryWithFieldLocalesResolved = ({ - entry, - contentTypesById, - getField, - defaultLocale, - - /** - * Keep track of entries we've already resolved, in case two or more entries - * have circular references (so as to prevent an infinite loop). - */ - resolvedEntryIDs = new Set(), -}) => { - const contentType = contentTypesById.get(entry.sys.contentType.sys.id) - - return { - ...entry, - fields: _.mapValues(entry.fields, (field, fieldName) => { - const fieldProps = getFieldProps(contentType, fieldName) - - const fieldValue = fieldProps.localized - ? getField(field) - : field[defaultLocale] +function renderRichText({ raw, references }, options = {}) { + const richText = JSON.parse(raw) - return getFieldWithLocaleResolved({ - field: fieldValue, - contentTypesById, - getField, - defaultLocale, - resolvedEntryIDs, - }) - }), + // If no references are given, there is no need to resolve them + if (!references || !references.length) { + return documentToReactComponents(richText, options) } -} -const getNormalizedRichTextNode = ({ - node, - contentTypesById, - getField, - defaultLocale, -}) => { - if (isEntryReferenceNode(node)) { - return { - ...node, - data: { - ...node.data, - target: getEntryWithFieldLocalesResolved({ - entry: node.data.target, - contentTypesById, - getField, - defaultLocale, - }), - }, - } - } + prepareIdsForResolving(richText) - if (isAssetReferenceNode(node)) { - return { - ...node, - data: { - ...node.data, - target: getAssetWithFieldLocalesResolved({ - asset: node.data.target, - getField, - }), + // Create dummy response so we can use official libraries for resolving the entries + const dummyResponse = { + items: [ + { + sys: { type: `Entry` }, + richText, }, - } - } - - if (Array.isArray(node.content)) { - return { - ...node, - content: node.content.map(childNode => - getNormalizedRichTextNode({ - node: childNode, - contentTypesById, - getField, - defaultLocale, - }) - ), - } + ], + includes: { + Entry: references + .filter(({ __typename }) => __typename === `ContentfulEntry`) + .map(reference => { + return { + ...reference, + sys: { type: `Entry`, id: reference.contentful_id }, + } + }), + Asset: references + .filter(({ __typename }) => __typename === `ContentfulAsset`) + .map(reference => { + return { + ...reference, + sys: { type: `Asset`, id: reference.contentful_id }, + } + }), + }, } - return node -} - -/** - * Walk through the rich-text object, resolving locales on referenced entries - * (and on entries they've referenced, etc.). - */ -const getNormalizedRichTextField = ({ - field, - contentTypesById, - getField, - defaultLocale, -}) => { - if (field && field.content) { - return { - ...field, - content: field.content.map(node => - getNormalizedRichTextNode({ - node, - contentTypesById, - getField, - defaultLocale, - }) - ), - } - } + const resolved = resolveResponse(dummyResponse, { + removeUnresolved: true, + }) - return field + return documentToReactComponents(resolved[0].richText, options) } -exports.getNormalizedRichTextField = getNormalizedRichTextField +exports.renderRichText = renderRichText