Gatsby Source Strapi plugin not processing images in dynamic zones

System Information
  • gatsby-source-strapi: 1.0.1
  • gatsby: 3.4.1

Hey,

in regards to this article I wonder if there is any way to make images in dynamic zones being recognized by gatsby transform sharp plugin as an image.
When I add a dynamic zone to my graphQL query a localFile___NODE is already created, but it does not get processed further. I assume this is because of the dynamic zone having JSON as type and the images won’t be recognized as image types.

How can I then get gatsbyImageData for these images?

Thanks in advance!

For those who are interested, this is my “dirty” solution:

I ended up creating a new object on the node in onCreateNode. Let’s say the dynamic zone is called “content” the new object would be called “contentImages”. Then I walked “content” (which is an array of objects which represent each components structure). For every component which I knew it had images, I used createRemoteFileNode to load the images and pushed an object containing the file node id.

exports.onCreateNode = async ({
  node,
  actions: { createNode },
  store,
  cache,
  createNodeId,
}) => {
  if (node.internal.type === "StrapiArticle") {
    if (node.content !== null && node.content.length) {
      let contentImages = [];
      for (let i = 0, len = node.content.length; i < len; i++) {
        const block = node.content[i];
        const blockImages = {};
        if (
          block.strapi_component === "content-blocks.gallery" &&
          block.images !== null &&
          block.images.length
        ) {
          blockImages.images = [];
          for (let j = 0, len2 = block.images.length; j < len2; j++) {
            let fileNode = await createRemoteFileNode({
              url: `${CMS_URL}${block.images[j].url}`, // string that points to the URL of the image
              parentNodeId: node.id, // id of the parent node of the fileNode you are going to create
              createNode, // helper function in gatsby-node to generate the node
              createNodeId, // helper function in gatsby-node to generate the node id
              cache, // Gatsby's cache
              store, // Gatsby's Redux store
            });
            blockImages.images.push(
              fileNode ? { localFile___NODE: fileNode.id } : {}
            );
          }
        }
        contentImages.push(blockImages);
      }
      node.contentImages = contentImages;
    }
  }

To make working with the data more comfortable I deep merged “content” and “contentImages” after receiving it from a graphql query which ended in having a localFile property with childImageSharp included.

3 Likes

Does it not handle the dynamic zone component types?
I thought the Gastby plugin do that.

Hey @LuisAlaguna,
it seems, type support is lost, because the whole dynamic zone is one type JSON. So gatsby-transform-sharp won’t recognize images in dynamic zones, because it is looking for type file, if I get it right.

So, you can either give a type to the component on dynamiz zone or render it with a markdown renderer.

You mean by overriding the JSON type? (like https://www.gatsbyjs.com/docs/reference/graphql-data-layer/schema-customization/)
I already played around with all of this stuff from that page and tried to build my own types, but always ended up with something like “Type definition…is not executable”.
All my customized types were visible in the gatsby graphql playground, but I was not able to override the json type.
I definitely have to learn typescript and graphql sdl. But i don’t have the time for it right now, unfortunately.

A markdown parser won’t make sense in this case, I think (or I don’t get the point).

@MatthiasDunker—Sorry I’m very new to Strapi customization, would you mind elaborating on a couple things? What file is your solution modifying—Is this a “policy” addition or modifying an existing file? Being a “dirty” solution, what long-term problems might this create?

And thank you—I’m exploring using Strapi as a replacement CMS for a Gatsby site (with most of the content within dynamic zones) and this is the only solution I’ve found for using Gatsby Image with Strapi Dynamic Zones.

Hey @Doug_Osborne ,
the dirty way is in the gatsby-node.js
There might be a way better solution which might be the way @LuisAlaguna intended it to be.
It is a mixture of gatsbys createSchemaCustomization and onCreateNode with createRemoteFileNode.
As this might be a common case, I will write a small article about it as soon as possible (it is a little bit more complex than the dirty solution, but gives you the right types for your GraphQL Queries).
Will keep you updated.

Awesome, much appreciated! I’m going to toy around with this over the next few days but there’s going to be a large learning curve for me. Looking forward to the article. I hope this is in the works as native functionality in gatsby-plugin-strapi.

@luisAlaguna if you could shed any more light on the solution you were proposing, that would be very helpful as well.

Thanks guys!

Well, i will share my schema for it:

import { alpha, Box } from "@material-ui/core";
import type { Theme } from "@material-ui/core/styles";
import { makeStyles } from "@material-ui/styles";
import clsx from "clsx";
import merge from "deepmerge";
import gh from "hast-util-sanitize/lib/github.json";
import Image from "next/image";
import React from "react";
import type { ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
import ReactPlayer from "react-player/lazy";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkAutolinkHeadings from "remark-autolink-headings";
import remarkSlug from "remark-slug";
import unwrapImages from "remark-unwrap-images";

import { getBlurUrlFromCloudinary } from "presentation/lib/helpers";
import remarkVideo from "presentation/lib/remarkVideo";
import useGlobalStyles from "presentation/styles/common";
import oceanic from "presentation/styles/material-oceanic";

const useStyles = makeStyles(({ breakpoints, palette }: Theme) => ({
  codeBlock: {
    "&::-webkit-scrollbar": {
      // display: "none",
      background: alpha(palette.light.main, 0.1),
    },
    "&::-webkit-scrollbar-track-piece": {
      display: "none",
    },
    "&::-webkit-scrollbar-thumb": {
      backgroundColor: alpha(palette.dark.main, 0.5),
    },
  },
  image: {
    position: "relative",
    margin: "auto",
    [breakpoints.down("xs")]: {
      // width: 400,
      height: 200,
    },
    [breakpoints.between("sm", "sm")]: {
      // width: 700,
      height: 500,
    },
    [breakpoints.up("md")]: {
      // width: 800,
      height: 600,
    },
  },
  playerWrapper: {
    position: "relative",
    paddingTop: "56.25%" /* Player ratio: 100 / (1280 / 720) */,
    margin: "30px auto",
  },
  reactPlayer: {
    position: "absolute",
    top: 0,
    left: 0,
  },
}));

const CodeBlock: React.FC<{ className?: string }> = ({ className }) => {
  const classes = useStyles();
  const match: RegExpExecArray | null = /language-(\w+)/.exec(className || "");
  return match ? (
    <SyntaxHighlighter
      showLineNumbers
      startingLineNumber={1}
      language={match[1]}
      style={oceanic}
      lineNumberContainerProps={{
        style: { color: "#ddd", paddingRight: "1.625em", float: "left" },
      }}
      wrapLines
      className={classes.codeBlock}
    />
  ) : (
    <code className={clsx(className, classes.codeBlock)} />
  );
};

interface IImageBlock {
  src?: string;
  alt?: string;
  title?: string;
}

const ImageBlock: React.FC<IImageBlock> = ({ alt, src, title }) => {
  const classes = useStyles();
  const globalClasses = useGlobalStyles();

  if (typeof src === "undefined")
    throw new TypeError("Next Image component requires a src attribute.");

  return (
    <Box className={clsx(classes.image, globalClasses.centeredImage)}>
      <Image
        alt={alt}
        src={src}
        quality={80}
        layout="fill"
        objectFit="contain"
        title={title}
        placeholder="blur"
        blurDataURL={getBlurUrlFromCloudinary(src)}
      />
    </Box>
  );
};

const VideoBlock: React.FC<{ src?: string }> = ({ src }) => {
  const classes = useStyles();
  return (
    <div className={classes.playerWrapper}>
      <ReactPlayer
        className={classes.reactPlayer}
        url={src}
        controls
        playing
        width="100%"
        height="100%"
      />
    </div>
  );
};

const _mapProps = (props: ReactMarkdownOptions): ReactMarkdownOptions => ({
  ...props,
  remarkPlugins: [
    // RemarkMathPlugin,
    // RemarkHighlightPlugin,
    remarkSlug,
    remarkAutolinkHeadings,
    unwrapImages,
    remarkVideo,
  ],
  rehypePlugins: [
    rehypeRaw,
    [rehypeSanitize, merge(gh, { attributes: { code: ["className"] } })],
  ],
  components: {
    ...props.components,
    // math: ({ value }) => <BlockMath>{value}</BlockMath>,
    // inlineMath: ({ value }) => <InlineMath>{value}</InlineMath>,
    code: CodeBlock,
    img: ImageBlock,
    video: VideoBlock,
  },
});

const Content: React.FC<ReactMarkdownOptions> = (props) => (
  <ReactMarkdown {..._mapProps(props)} />
);

export default Content;

This is intended for a rich text field.

For dynamic zone:

const DynamicZone: React.FC<IDynamicZone> = ({ component, className }) => {
  const classes = useStyles();

  const selectComponent = () => {
    switch (component.__typename) {
      case "ComponentContentRichText":
        return <Content>{component.text}</Content>;
      case "ComponentContentExperience":
        return <Experience {...component} last={false} />;
      case "ComponentContentPersonalInformation":
        return <PersonalInformation {...component} />;
      case "ComponentFieldsSkill":
        return <Skill {...component} />;
    }
  };

  return (
    <Typography
      variant="body1"
      component="section"
      className={clsx(classes.dynamicZone, className)}
    >
      {selectComponent()}
      {/*
      {
        {
          "content.rich-text": <Content>{component.text}</Content>,
          "content.experience": <Experience {...component} />,
          "content.personal-information": (
            <PersonalInformation {...component} />
          ),
          "fields.skill": <Skill {...component} />,
        }[component.__component]
      }
      */}
    </Typography>
  );
};

export default DynamicZone;

Thank you very much for this code snippet. I don’t think this is a dirty solution as long as it is approached systematically.

I have a dynamic zone that allows the user to either input a RichText block or an Image block and the code is this:

gatsby-node

exports.onCreateNode = async ({
  node,
  actions: { createNode },
  store,
  cache,
  createNodeId,
}) => {
  if (node.internal.type === "StrapiPost") {
    if (node.sections !== null && node.sections.length) {
      let contentImages = []
      for (let i = 0, len = node.sections.length; i < len; i++) {
        const block = node.sections[i]
        let blockImage = {}
        if (block.strapi_component === "posts.image" && block.image !== null) {
          let fileNode = await createRemoteFileNode({
            url: `${CMS_URL}${block.image.url}`, // string that points to the URL of the image
            parentNodeId: node.id, // id of the parent node of the fileNode you are going to create
            createNode, // helper function in gatsby-node to generate the node
            createNodeId, // helper function in gatsby-node to generate the node id
            cache, // Gatsby's cache
            store, // Gatsby's Redux store
          })
          blockImage = fileNode ? { localFile___NODE: fileNode.id } : {}
        }
        contentImages.push(blockImage)
      }
      node.sectionImages = contentImages
    }
  }
}

And the associated post output graphQL Query

export const query = graphql`
  query Post($id: String!) {
    post: strapiPost(id: { eq: $id }) {
      title
      excerpt
      slug
      featured_image {
        localFile {
          id
          childImageSharp {
            gatsbyImageData(layout: FULL_WIDTH, placeholder: NONE)
          }
        }
      }
      published_at
      updated_at
      categories {
        id
        title
        slug
      }
      sections
      sectionImages {
        localFile {
          id
          childImageSharp {
            gatsbyImageData(layout: FULL_WIDTH, placeholder: NONE)
          }
        }
      }
    }
  }
`

And Components

const Post = ({ data, pageContext }) => {
  const { post } = data
  const { title, featured_image, categories, sections, sectionImages } = post

  let imageObject = null
  if (featured_image) {
    imageObject = getImage(featured_image.localFile)
  }
  return (
    <Layout>
          <Grid isPost={true}>
              {imageObject && (
                <GatsbyImage
                  image={imageObject}
                  alt={title}
                  width={1072}
                  height={618}
                />
              )}
              <H2>{title}</H2>
              {sections.map( (section, i) =>
                RenderSection(section, sectionImages[i], i)
              )}
          </Grid>
    </Layout>
  )
}

const RenderSection = (section, sectionImage, i) => {
  switch (section.strapi_component) {
    case "posts.content":
      return (
        <div key={`section-rich-${i}`}>
          <RichText content={section.content} />
        </div>
      )
    case "posts.image":
      return (
        <div key={`section-image-${i}`}>
          <ImageComponent data={section} image={sectionImage} index={i} />
        </div>
      )
  }
}

const ImageComponent = ({ data, image = null, index }) => {
  let imageObject = null
  const { caption = false } = data
  if (image) {
    imageObject = getImage(image.localFile)
  }

  return (
    <>
      {imageObject && (
        <GatsbyImage
          image={imageObject}
          alt={caption}
          width={1072}
          height={618}
        />
      )}
      {caption && (
        <Xs>{caption}</Xs>
      )}
    </>
  )
}