diff --git a/e2e-tests/contentful/src/pages/rich-text.js b/e2e-tests/contentful/src/pages/rich-text.js index fc066c7bda5ad..64d07ee69a9be 100644 --- a/e2e-tests/contentful/src/pages/rich-text.js +++ b/e2e-tests/contentful/src/pages/rich-text.js @@ -20,13 +20,13 @@ function renderReferencedComponent(ref) { return } -const options = { +const makeOptions = ({ assetBlockMap, entryBlockMap, entryInlineMap }) => ({ renderMark: { [MARKS.BOLD]: text => {text}, }, renderNode: { [BLOCKS.EMBEDDED_ASSET]: node => { - const asset = node.data.target + const asset = assetBlockMap.get(node?.data?.target?.sys.id) if (asset.fluid) { return } @@ -40,7 +40,7 @@ const options = { ) }, [BLOCKS.EMBEDDED_ENTRY]: node => { - const entry = node?.data?.target + const entry = entryBlockMap.get(node?.data?.target?.sys.id) if (!entry) { throw new Error( `Entity not available for node:\n${JSON.stringify(node, null, 2)}` @@ -49,7 +49,7 @@ const options = { return renderReferencedComponent(entry) }, [INLINES.EMBEDDED_ENTRY]: node => { - const entry = node.data.target + const entry = entryInlineMap.get(node?.data?.target?.sys.id) if (entry.__typename === "ContentfulText") { return ( @@ -64,7 +64,7 @@ const options = { ) }, }, -} +}) const RichTextPage = ({ data }) => { const entries = data.allContentfulRichText.nodes @@ -75,7 +75,7 @@ const RichTextPage = ({ data }) => { return (

{title}

- {renderRichText(richText, options)} + {renderRichText(richText, makeOptions)}
) @@ -93,77 +93,94 @@ export const pageQuery = graphql` id title richText { - raw - references { - __typename - sys { - id - } - ... on ContentfulAsset { - fluid(maxWidth: 200) { - ...GatsbyContentfulFluid - } - } - ... on ContentfulText { - title - short - } - ... on ContentfulLocation { - location { - lat - lon + json + links { + assets { + block { + sys { + id + } + fluid(maxWidth: 200) { + ...GatsbyContentfulFluid + } } } - ... on ContentfulContentReference { - title - one { + entries { + block { __typename sys { id + type } ... on ContentfulText { title short } + ... on ContentfulLocation { + location { + lat + lon + } + } ... on ContentfulContentReference { title one { + __typename + sys { + id + } + ... on ContentfulText { + title + short + } ... on ContentfulContentReference { title + one { + ... on ContentfulContentReference { + title + } + } + many { + ... on ContentfulContentReference { + title + } + } } } many { + __typename + sys { + id + } + ... on ContentfulText { + title + short + } + ... on ContentfulNumber { + title + integer + } ... on ContentfulContentReference { title + one { + ... on ContentfulContentReference { + title + } + } + many { + ... on ContentfulContentReference { + title + } + } } } } } - many { + inline { __typename sys { id - } - ... on ContentfulText { - title - short - } - ... on ContentfulNumber { - title - integer - } - ... on ContentfulContentReference { - title - one { - ... on ContentfulContentReference { - title - } - } - many { - ... on ContentfulContentReference { - title - } - } + type } } } diff --git a/packages/gatsby-source-contentful/package.json b/packages/gatsby-source-contentful/package.json index 8f86bb274d827..e5872d55f0535 100644 --- a/packages/gatsby-source-contentful/package.json +++ b/packages/gatsby-source-contentful/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", + "@contentful/rich-text-links": "^14.1.2", "@contentful/rich-text-react-renderer": "^14.1.2", "@contentful/rich-text-types": "^14.1.2", "@hapi/joi": "^15.1.1", diff --git a/packages/gatsby-source-contentful/src/__tests__/rich-text.js b/packages/gatsby-source-contentful/src/__tests__/rich-text.js index c8f558c4c00b4..cbcd8c13f2463 100644 --- a/packages/gatsby-source-contentful/src/__tests__/rich-text.js +++ b/packages/gatsby-source-contentful/src/__tests__/rich-text.js @@ -2,10 +2,11 @@ import React from "react" import { render } from "@testing-library/react" import { renderRichText } from "gatsby-source-contentful/rich-text" import { BLOCKS, INLINES } from "@contentful/rich-text-types" +import { getRichTextEntityLinks } from "@contentful/rich-text-links" import { initialSync } from "../__fixtures__/rich-text-data" import { cloneDeep } from "lodash" -const raw = { +const json = { nodeType: `document`, data: {}, content: [ @@ -404,101 +405,128 @@ const raw = { } const fixtures = initialSync().currentSyncData +const fixturesEntriesMap = new Map() +const fixturesAssetsMap = new Map() -const references = [ - ...fixtures.entries.map(entity => { - return { - sys: entity.sys, - __typename: `ContentfulContent`, - ...entity.fields, - } - }), - ...fixtures.assets.map(entity => { - return { - sys: entity.sys, - __typename: `ContentfulAsset`, - ...entity.fields, - } - }), -] +fixtures.entries.forEach(entity => + fixturesEntriesMap.set(entity.sys.id, { sys: entity.sys, ...entity.fields }) +) +fixtures.assets.forEach(entity => + fixturesAssetsMap.set(entity.sys.id, { sys: entity.sys, ...entity.fields }) +) + +const links = { + assets: { + block: getRichTextEntityLinks(json, `embedded-asset-block`)[ + `Asset` + ].map(entity => fixturesAssetsMap.get(entity.id)), + hyperlink: getRichTextEntityLinks(json, `asset-hyperlink`)[ + `Asset` + ].map(entity => fixturesAssetsMap.get(entity.id)), + }, + entries: { + inline: getRichTextEntityLinks(json, `embedded-entry-inline`)[ + `Entry` + ].map(entity => fixturesEntriesMap.get(entity.id)), + block: getRichTextEntityLinks(json, `embedded-entry-block`)[ + `Entry` + ].map(entity => fixturesEntriesMap.get(entity.id)), + hyperlink: getRichTextEntityLinks(json, `entry-hyperlink`)[ + `Entry` + ].map(entity => fixturesEntriesMap.get(entity.id)), + }, +} describe(`rich text`, () => { test(`renders with default options`, () => { const { container } = render( - renderRichText({ raw: cloneDeep(raw), references: cloneDeep(references) }) + renderRichText({ json: cloneDeep(json), links: cloneDeep(links) }) ) expect(container).toMatchSnapshot() }) test(`renders with custom options`, () => { - const options = { - renderNode: { - [INLINES.EMBEDDED_ENTRY]: node => { - if (!node.data.target) { - return ( - - Unresolved INLINE ENTRY: {JSON.stringify(node, null, 2)} - - ) - } - return Resolved inline Entry ({node.data.target.sys.id}) - }, - [INLINES.ENTRY_HYPERLINK]: node => { - if (!node.data.target) { - return ( - - Unresolved ENTRY HYPERLINK: {JSON.stringify(node, null, 2)} - - ) - } - return ( - Resolved entry Hyperlink ({node.data.target.sys.id}) - ) - }, - [INLINES.ASSET_HYPERLINK]: node => { - if (!node.data.target) { - return ( - - Unresolved ASSET HYPERLINK: {JSON.stringify(node, null, 2)} - - ) - } - return ( - Resolved asset Hyperlink ({node.data.target.sys.id}) - ) - }, - [BLOCKS.EMBEDDED_ENTRY]: node => { - if (!node.data.target) { + const makeOptions = ({ + assetBlockMap, + assetHyperlinkMap, + entryBlockMap, + entryInlineMap, + entryHyperlinkMap, + }) => { + return { + renderNode: { + [INLINES.EMBEDDED_ENTRY]: node => { + const entry = entryInlineMap.get(node?.data?.target?.sys.id) + if (!entry) { + return ( + + Unresolved INLINE ENTRY:{` `} + {JSON.stringify({ node, entryInlineMap }, null, 2)} + + ) + } + return Resolved inline Entry ({entry.sys.id}) + }, + [INLINES.ENTRY_HYPERLINK]: node => { + const entry = entryHyperlinkMap.get(node?.data?.target?.sys.id) + if (!entry) { + return ( + + Unresolved ENTRY HYPERLINK: {JSON.stringify(node, null, 2)} + + ) + } + return Resolved entry Hyperlink ({entry.sys.id}) + }, + [INLINES.ASSET_HYPERLINK]: node => { + const entry = assetHyperlinkMap.get(node?.data?.target?.sys.id) + if (!entry) { + return ( + + Unresolved ASSET HYPERLINK: {JSON.stringify(node, null, 2)} + + ) + } + return Resolved asset Hyperlink ({entry.sys.id}) + }, + [BLOCKS.EMBEDDED_ENTRY]: node => { + const entry = entryBlockMap.get(node?.data?.target?.sys.id) + if (!entry) { + return ( +
+ Unresolved ENTRY !!!!": {JSON.stringify(node, null, 2)} +
+ ) + } return ( -
Unresolved ENTRY !!!!": {JSON.stringify(node, null, 2)}
+

+ Resolved embedded Entry: {entry.title[`en-US`]} ({entry.sys.id}) +

) - } - return ( -

- Resolved embedded Entry: {node.data.target.title[`en-US`]} ( - {node.data.target.sys.id}) -

- ) - }, - [BLOCKS.EMBEDDED_ASSET]: node => { - if (!node.data.target) { + }, + [BLOCKS.EMBEDDED_ASSET]: node => { + const entry = assetBlockMap.get(node?.data?.target?.sys.id) + if (!entry) { + return ( +
+ Unresolved ASSET !!!!": {JSON.stringify(node, null, 2)} +
+ ) + } return ( -
Unresolved ASSET !!!!": {JSON.stringify(node, null, 2)}
+

+ Resolved embedded Asset: {entry.title[`en-US`]} ({entry.sys.id}) +

) - } - return ( -

- Resolved embedded Asset: {node.data.target.title[`en-US`]} ( - {node.data.target.sys.id}) -

- ) + }, }, - }, + } } + const { container } = render( renderRichText( - { raw: cloneDeep(raw), references: cloneDeep(references) }, - options + { json: cloneDeep(json), links: cloneDeep(links) }, + makeOptions ) ) expect(container).toMatchSnapshot() diff --git a/packages/gatsby-source-contentful/src/generate-schema.js b/packages/gatsby-source-contentful/src/generate-schema.js index c946e82fe2165..9e2b8895ac86f 100644 --- a/packages/gatsby-source-contentful/src/generate-schema.js +++ b/packages/gatsby-source-contentful/src/generate-schema.js @@ -1,3 +1,5 @@ +import { getRichTextEntityLinks } from "@contentful/rich-text-links" + import { makeTypeName } from "./normalize" // Contentful content type schemas @@ -143,6 +145,7 @@ export function generateSchema({ pluginConfig, contentTypeItems, }) { + // Generic Types createTypes(` interface ContentfulInternalReference implements Node { id: ID! @@ -180,59 +183,100 @@ export function generateSchema({ } `) + // Assets generateAssetTypes({ createTypes }) + // Rich Text + const makeRichTextLinksResolver = (nodeType, entityType) => ( + source, + args, + context + ) => { + const links = getRichTextEntityLinks(source, nodeType)[entityType].map( + ({ id }) => id + ) + + return context.nodeModel.getAllNodes().filter( + node => + node.internal.owner === `gatsby-source-contentful` && + node?.sys?.id && + node?.sys?.type === entityType && + links.includes(node.sys.id) + // @todo how can we check for correct space and environment? We need to access the sys field of the fields parent entry. + ) + } + // Contentful specific types + createTypes( + schema.buildObjectType({ + name: `ContentfulNodeTypeRichTextAssets`, + fields: { + block: { + type: `[ContentfulAsset]!`, + resolve: makeRichTextLinksResolver(`embedded-asset-block`, `Asset`), + }, + hyperlink: { + type: `[ContentfulAsset]!`, + resolve: makeRichTextLinksResolver(`asset-hyperlink`, `Asset`), + }, + }, + }) + ) + + createTypes( + schema.buildObjectType({ + name: `ContentfulNodeTypeRichTextEntries`, + fields: { + inline: { + type: `[ContentfulEntry]!`, + resolve: makeRichTextLinksResolver(`embedded-entry-inline`, `Entry`), + }, + block: { + type: `[ContentfulEntry]!`, + resolve: makeRichTextLinksResolver(`embedded-entry-block`, `Entry`), + }, + hyperlink: { + type: `[ContentfulEntry]!`, + resolve: makeRichTextLinksResolver(`entry-hyperlink`, `Entry`), + }, + }, + }) + ) + + createTypes( + schema.buildObjectType({ + name: `ContentfulNodeTypeRichTextLinks`, + fields: { + assets: { + type: `ContentfulNodeTypeRichTextAssets`, + resolve(source) { + return source + }, + }, + entries: { + type: `ContentfulNodeTypeRichTextEntries`, + resolve(source) { + return source + }, + }, + }, + }) + ) + createTypes( schema.buildObjectType({ name: `ContentfulNodeTypeRichText`, fields: { - raw: { + json: { type: `JSON`, resolve(source) { return source }, }, - references: { - type: `[ContentfulInternalReference]`, - resolve(source, args, context) { - const referencedEntries = new Set() - const referencedAssets = new Set() - - // Locate all Contentful Links within the rich text data - // Traverse logic based on https://github.com/contentful/contentful-resolve-response - const traverse = obj => { - // eslint-disable-next-line guard-for-in - for (const k in obj) { - const v = obj[k] - if (v && v.sys && v.sys.type === `Link`) { - if (v.sys.linkType === `Asset`) { - referencedAssets.add(v.sys.id) - } - if (v.sys.linkType === `Entry`) { - referencedEntries.add(v.sys.id) - } - } else if (v && typeof v === `object`) { - traverse(v) - } - } - } - traverse(source) - - // Get all nodes and return all that got referenced in the rich text - return context.nodeModel.getAllNodes().filter(node => { - if ( - !( - node.internal.owner === `gatsby-source-contentful` && - node?.sys?.id - ) - ) { - return false - } - return node.internal.type === `ContentfulAsset` - ? referencedAssets.has(node.sys.id) - : referencedEntries.has(node.sys.id) - }) + links: { + type: `ContentfulNodeTypeRichTextLinks`, + resolve(source) { + return source }, }, }, @@ -240,6 +284,7 @@ export function generateSchema({ }) ) + // Location createTypes( schema.buildObjectType({ name: `ContentfulNodeTypeLocation`, @@ -253,6 +298,7 @@ export function generateSchema({ }) ) + // Text // @todo Is there a way to have this as string and let transformer-remark replace it with an object? createTypes( schema.buildObjectType({ @@ -267,6 +313,7 @@ export function generateSchema({ }) ) + // Content types for (const contentTypeItem of contentTypeItems) { try { const fields = {} diff --git a/packages/gatsby-source-contentful/src/rich-text.js b/packages/gatsby-source-contentful/src/rich-text.js index 31506a29996ff..890f3ac910144 100644 --- a/packages/gatsby-source-contentful/src/rich-text.js +++ b/packages/gatsby-source-contentful/src/rich-text.js @@ -1,47 +1,51 @@ import { documentToReactComponents } from "@contentful/rich-text-react-renderer" -import resolveResponse from "contentful-resolve-response" -function renderRichText({ raw, references }, options = {}) { - const richText = raw +function renderRichText({ json, links }, makeOptions = {}) { + const options = + typeof makeOptions === `function` + ? makeOptions(generateLinkMaps(links)) + : makeOptions - // If no references are given, there is no need to resolve them - if (!references || !references.length) { - return documentToReactComponents(richText, options) + return documentToReactComponents(json, options) +} + +exports.renderRichText = renderRichText + +/** + * Helper function to simplify Rich Text rendering. Based on: + * https://www.contentful.com/blog/2021/04/14/rendering-linked-assets-entries-in-contentful/ + */ +function generateLinkMaps(links) { + const assetBlockMap = new Map() + for (const asset of links.assets.block || []) { + assetBlockMap.set(asset.sys.id, asset) } - // Create dummy response so we can use official libraries for resolving the entries - const dummyResponse = { - items: [ - { - sys: { type: `Entry` }, - richText, - }, - ], - includes: { - Entry: references - .filter(({ __typename }) => __typename !== `ContentfulAsset`) - .map(reference => { - return { - ...reference, - sys: { type: `Entry`, id: reference.sys.id }, - } - }), - Asset: references - .filter(({ __typename }) => __typename === `ContentfulAsset`) - .map(reference => { - return { - ...reference, - sys: { type: `Asset`, id: reference.sys.id }, - } - }), - }, + const assetHyperlinkMap = new Map() + for (const asset of links.assets.hyperlink || []) { + assetHyperlinkMap.set(asset.sys.id, asset) } - const resolved = resolveResponse(dummyResponse, { - removeUnresolved: true, - }) + const entryBlockMap = new Map() + for (const entry of links.entries.block || []) { + entryBlockMap.set(entry.sys.id, entry) + } - return documentToReactComponents(resolved[0].richText, options) -} + const entryInlineMap = new Map() + for (const entry of links.entries.inline || []) { + entryInlineMap.set(entry.sys.id, entry) + } -exports.renderRichText = renderRichText + const entryHyperlinkMap = new Map() + for (const entry of links.entries.hyperlink || []) { + entryHyperlinkMap.set(entry.sys.id, entry) + } + + return { + assetBlockMap, + assetHyperlinkMap, + entryBlockMap, + entryInlineMap, + entryHyperlinkMap, + } +} diff --git a/yarn.lock b/yarn.lock index 37b8922beee49..b5b2626c40d15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1428,6 +1428,13 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@contentful/rich-text-links@^14.1.2": + version "14.1.2" + resolved "https://registry.yarnpkg.com/@contentful/rich-text-links/-/rich-text-links-14.1.2.tgz#993cd086d55af11f5d31b76060c02a9866c93a01" + integrity sha512-oK+y/c42fOJOdRM6XDKNLqw7uwVHZUIRGKzk9fJLDaOt5tygDm0pvgJ9bkvadaBwbAioxlQ0hHS0i5JP+UHkvA== + dependencies: + "@contentful/rich-text-types" "^14.1.2" + "@contentful/rich-text-react-renderer@^14.1.2": version "14.1.2" resolved "https://registry.yarnpkg.com/@contentful/rich-text-react-renderer/-/rich-text-react-renderer-14.1.2.tgz#b7fff19faa0512f034f1717774a0d9b348bb07fc"