diff --git a/.changeset/thick-eggs-admire.md b/.changeset/thick-eggs-admire.md new file mode 100644 index 000000000..ecd482ded --- /dev/null +++ b/.changeset/thick-eggs-admire.md @@ -0,0 +1,13 @@ +--- +"gatsby-source-strapi": major +--- + +Support Strapi v5, while staying compatible with v4. + +There are no breaking changes. I've created a major release because I don't want to bother current applications with possible bugs. Updrading to this code should be a choice. + +I have removed the code for creating the unstable_createNodeManifest. In my believe, this was only for Gatsby Cloud and as Gatsby Cloud only remains in our sweet memories of the glory days, we don't need it anymore. I've also deleted the readme about Gatsby Cloud and content sync, I don't believe any plaform is supporting content sync right now, or ever again. + +I have tried to support both v4 and v5 syntaxes in this release. By setting `version` on your config to 5, the new REST API syntax will be used. `publicationState=preview` will automatically be rewritten to `status=draft`. + +As Strapi v5 is using documentId's over regular id's, I am now using the documentId (where available, f.e. not in components) to create the Gatsby Node id. This should keep updated relations intact when reloading data. diff --git a/eslint.config.js b/eslint.config.js index 2d37f714c..54c3ccbef 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,4 @@ +const globals = require("globals"); const ts_parser = require("@typescript-eslint/parser"); const babel_parser = require("@babel/eslint-parser"); const unicorn = require("eslint-plugin-unicorn"); @@ -77,4 +78,16 @@ module.exports = [ ...typescript.configs.recommended.rules, }, }, + { + files: ["packages/gatsby-source-strapi/**/*.js"], + rules: { + "no-undef": "error", + "no-unused-vars": "error", + }, + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, ]; diff --git a/packages/gatsby-source-strapi/README.md b/packages/gatsby-source-strapi/README.md index a741c9df9..f35c4a4e1 100644 --- a/packages/gatsby-source-strapi/README.md +++ b/packages/gatsby-source-strapi/README.md @@ -2,7 +2,7 @@ Source plugin for pulling documents into Gatsby from a Strapi API. -> ⚠️ This version of `gatsby-source-strapi` is only compatible with Strapi v4. For v3 use this [release](https://www.npmjs.com/package/gatsby-source-strapi/v/1.0.3) +> ⚠️ This version of `gatsby-source-strapi` is only compatible with Strapi v5 and v4. For v3 use this [release](https://www.npmjs.com/package/gatsby-source-strapi/v/1.0.3) _This plugin is now maintained publicly by the Gatsby User Collective. Join us in maintaining this piece of software._ @@ -73,6 +73,7 @@ require("dotenv").config({ }); const strapiConfig = { + version: 4, // Strapi version 4 or 5 apiURL: process.env.STRAPI_API_URL, accessToken: process.env.STRAPI_TOKEN, collectionTypes: ["article", "company", "author"], @@ -151,6 +152,9 @@ const strapiConfig = { singularName: "article", queryParams: { publicationState: process.env.GATSBY_IS_PREVIEW === "true" ? "preview" : "live", + // or the v5 format, + // we will automatically rewrite publicationState to status: + // status: process.env.GATSBY_IS_PREVIEW === "true" ? "draft" : "published", populate: { category: { populate: "*" }, cover: "*", @@ -370,57 +374,6 @@ Then use the one of the following queries to fetch a localized content type: } ``` -## Gatsby cloud and preview environment setup - -### Setup - -To enable content sync in [Gatsby cloud](https://www.gatsbyjs.com/docs/how-to/previews-deploys-hosting/deploying-to-gatsby-cloud/) you need to create two webhooks in your Strapi project: - -- [Build webhook](https://support.gatsbyjs.com/hc/en-us/articles/360052324394-Build-and-Preview-Webhooks) - -![webhook setup](./assets/webhook.png) - -- [Preview webhook](https://support.gatsbyjs.com/hc/en-us/articles/360052324394-Build-and-Preview-Webhooks) - -At this point each time you create an entry the webhooks will trigger a new build a deploy your new Gatsby site. - -In the Site settings, Environment variables fill the: - -- Build variables with the following: - - STRAPI_API_URL with the url of your deployed Strapi application - - STRAPI_TOKEN with your build API token -- Preview variables: - - STRAPI_API_URL with the url of your deployed Strapi application - - STRAPI_TOKEN with your preview API token - -### Enabling Content Sync - -#### Installing the @strapi/plugin-gatsby-preview - -In order to enable Gatsby Content Sync and integrate it in your Strapi CMS you need to install the `@strapi/plugin-gatsby-preview` in your Strapi project: - -##### Using yarn - -```sh -cd my-strapi-app - -yarn add @strapi/plugin-gatsby-preview -``` - -##### Using npm - -```sh -cd my-strapi-app -npm install --save @strapi/plugin-gatsby-preview -``` - -#### Configurations - -Once the plugin is installed, you will need to configure it in the plugin's settings section. - -- In the Collection types or the Single Types tab, when enabling the **preview**, it will inject a button in the content manager edit view of the corresponding content type. So, after creating an entry (draft or published), clicking on the **Open Gatsby preview** button will redirect you to the Gatsby preview page -- In the Settings tab, enter the Gatsby Content Sync URL. You can find it in Gatsby cloud under "Site settings" and "Content Sync". - ## Restrictions and limitations This plugin has several limitations, please be aware of these: diff --git a/packages/gatsby-source-strapi/src/axios-instance.js b/packages/gatsby-source-strapi/src/axios-instance.js index 00c2f1615..9ada691d9 100644 --- a/packages/gatsby-source-strapi/src/axios-instance.js +++ b/packages/gatsby-source-strapi/src/axios-instance.js @@ -13,7 +13,7 @@ const throttlingInterceptors = (axiosInstance, maxParallelRequests) => { /** Axios Request Interceptor */ axiosInstance.interceptors.request.use(function (config) { - return new Promise((resolve, _) => { + return new Promise((resolve) => { let interval = setInterval(() => { if (PENDING_REQUESTS < maxParallelRequests) { PENDING_REQUESTS++; diff --git a/packages/gatsby-source-strapi/src/clean-data.js b/packages/gatsby-source-strapi/src/clean-data.js index 743f11644..e767fc743 100644 --- a/packages/gatsby-source-strapi/src/clean-data.js +++ b/packages/gatsby-source-strapi/src/clean-data.js @@ -3,6 +3,7 @@ import _ from "lodash"; import { getContentTypeSchema } from "./helpers"; const MEDIA_FIELDS = [ + "id", "name", "alternativeText", "caption", @@ -17,10 +18,37 @@ const MEDIA_FIELDS = [ "previewUrl", "createdAt", "updatedAt", + "documentId", + "publishedAt", ]; const restrictedFields = new Set(["__component", `children`, `fields`, `internal`, `parent`]); +const getValue = (value, version) => { + if (!value) { + return; + } + if (version === 4) { + return value?.data; + } + // assume v5 + return value; +}; + +const getAttributes = (data, version) => { + if (!data) { + return; + } + if (version === 4) { + return { + id: data.id, + ...data.attributes, + }; + } + // assume v5 + return data; +}; + /** * Removes the attribute key in the entire data. * @param {Object} attributes response from the API @@ -28,7 +56,7 @@ const restrictedFields = new Set(["__component", `children`, `fields`, `internal * @param {*} schemas * @returns */ -export const cleanAttributes = (attributes, currentSchema, schemas) => { +export const cleanAttributes = (attributes, currentSchema, schemas, version = 4) => { if (!attributes) { return; } @@ -69,7 +97,7 @@ export const cleanAttributes = (attributes, currentSchema, schemas) => { [attributeName]: value.map((v) => { const compoSchema = getContentTypeSchema(schemas, v.__component); - return cleanAttributes(v, compoSchema, schemas); + return cleanAttributes(v, compoSchema, schemas, version); }), }; } @@ -82,25 +110,27 @@ export const cleanAttributes = (attributes, currentSchema, schemas) => { return { ...accumulator, [attributeName]: value.map((v) => { - return cleanAttributes(v, compoSchema, schemas); + return cleanAttributes(v, compoSchema, schemas, version); }), }; } return { ...accumulator, - [attributeName]: cleanAttributes(value, compoSchema, schemas), + [attributeName]: cleanAttributes(value, compoSchema, schemas, version), }; } + // make sure we can use both v4 and v5 outputs + const valueData = getValue(value, version); + if (attribute.type === "media") { - if (Array.isArray(value?.data)) { + if (Array.isArray(valueData)) { return { ...accumulator, - [attributeName]: value.data - ? value.data.map(({ id, attributes }) => ({ - id, - ..._.pick(attributes, MEDIA_FIELDS), + [attributeName]: valueData + ? valueData.map((data) => ({ + ..._.pick(getAttributes(data, version), MEDIA_FIELDS), })) : undefined, }; @@ -108,10 +138,9 @@ export const cleanAttributes = (attributes, currentSchema, schemas) => { return { ...accumulator, - [attributeName]: value.data + [attributeName]: valueData ? { - id: value.data.id, - ..._.pick(value.data.attributes, MEDIA_FIELDS), + ..._.pick(getAttributes(valueData, version), MEDIA_FIELDS), } : undefined, }; @@ -119,12 +148,11 @@ export const cleanAttributes = (attributes, currentSchema, schemas) => { if (attribute.type === "relation") { const relationSchema = getContentTypeSchema(schemas, attribute.target); - - if (Array.isArray(value?.data)) { + if (Array.isArray(valueData)) { return { ...accumulator, - [attributeName]: value.data.map(({ id, attributes }) => - cleanAttributes({ id, ...attributes }, relationSchema, schemas), + [attributeName]: valueData.map((data) => + cleanAttributes(getAttributes(data, version), relationSchema, schemas, version), ), }; } @@ -132,9 +160,10 @@ export const cleanAttributes = (attributes, currentSchema, schemas) => { return { ...accumulator, [attributeName]: cleanAttributes( - value.data ? { id: value.data.id, ...value.data.attributes } : undefined, + getAttributes(valueData, version) || undefined, relationSchema, schemas, + version, ), }; } @@ -152,13 +181,10 @@ export const cleanAttributes = (attributes, currentSchema, schemas) => { * @param {Object} ctx * @returns {Object} */ -export const cleanData = ({ id, attributes, ...rest }, context) => { +export const cleanData = (data, context, version = 4) => { const { schemas, contentTypeUid } = context; const currentContentTypeSchema = getContentTypeSchema(schemas, contentTypeUid); - return { - id, - ...rest, - ...cleanAttributes(attributes, currentContentTypeSchema, schemas), + ...cleanAttributes(getAttributes(data, version), currentContentTypeSchema, schemas, version), }; }; diff --git a/packages/gatsby-source-strapi/src/fetch.js b/packages/gatsby-source-strapi/src/fetch.js index d9e13ff15..0732b5df2 100644 --- a/packages/gatsby-source-strapi/src/fetch.js +++ b/packages/gatsby-source-strapi/src/fetch.js @@ -22,14 +22,34 @@ export const fetchStrapiContentTypes = async (axiosInstance) => { }; }; -export const fetchEntity = async ({ endpoint, queryParams, uid, pluginOptions }, context) => { +const convertQueryParameters = (queryParameters, version = 4) => { + if (version == 4) { + return queryParameters; + } + // assume v5. + // rewrite v4 publicationState=preview to status=draft + // https://docs.strapi.io/dev-docs/migration/v4-to-v5/breaking-changes/publication-state-removed + const { publicationState, ...rest } = queryParameters; + if (publicationState !== "preview") { + return queryParameters; + } + return { + ...rest, + status: "draft", + }; +}; + +export const fetchEntity = async ( + { endpoint, queryParams, uid, pluginOptions, version = 4 }, + context, +) => { const { reporter, axiosInstance } = context; /** @type AxiosRequestConfig */ const options = { method: "GET", url: endpoint, - params: queryParams, + params: convertQueryParameters(queryParams, version), // Source: https://github.com/axios/axios/issues/5058#issuecomment-1379970592 paramsSerializer: { serialize: (parameters) => qs.stringify(parameters, { encodeValuesOnly: true }), @@ -91,7 +111,7 @@ export const fetchEntity = async ({ endpoint, queryParams, uid, pluginOptions }, const otherLocalizationsData = await Promise.all(otherLocalizationsPromises); return castArray([data.data, ...otherLocalizationsData]).map((entry) => - cleanData(entry, { ...context, contentTypeUid: uid }), + cleanData(entry, { ...context, contentTypeUid: uid }, version), ); } catch (error) { if (error.response.status !== 404) { @@ -104,14 +124,17 @@ export const fetchEntity = async ({ endpoint, queryParams, uid, pluginOptions }, } }; -export const fetchEntities = async ({ endpoint, queryParams, uid, pluginOptions }, context) => { +export const fetchEntities = async ( + { endpoint, queryParams, uid, pluginOptions, version = 4 }, + context, +) => { const { reporter, axiosInstance } = context; /** @type AxiosRequestConfig */ const options = { method: "GET", url: endpoint, - params: queryParams, + params: convertQueryParameters(queryParams, version), paramsSerializer: { serialize: (parameters) => qs.stringify(parameters, { encodeValuesOnly: true }), }, @@ -176,7 +199,7 @@ export const fetchEntities = async ({ endpoint, queryParams, uid, pluginOptions const results = await Promise.all(fetchPagesPromises); const cleanedData = [...data, ...flattenDeep(results)].map((entry) => - cleanData(entry, { ...context, contentTypeUid: uid }), + cleanData(entry, { ...context, contentTypeUid: uid }, version), ); return cleanedData; diff --git a/packages/gatsby-source-strapi/src/gatsby-node.js b/packages/gatsby-source-strapi/src/gatsby-node.js index ba6efff4c..fd09dc257 100644 --- a/packages/gatsby-source-strapi/src/gatsby-node.js +++ b/packages/gatsby-source-strapi/src/gatsby-node.js @@ -1,11 +1,6 @@ import { fetchStrapiContentTypes, fetchEntities, fetchEntity } from "./fetch"; import { downloadMediaFiles } from "./download-media-files"; -import { - buildMapFromNodes, - buildNodesToRemoveMap, - getEndpoints, - makeParentNodeName, -} from "./helpers"; +import { buildMapFromNodes, buildNodesToRemoveMap, getEndpoints } from "./helpers"; import { createNodes } from "./normalize"; import { createAxiosInstance } from "./axios-instance"; @@ -27,6 +22,8 @@ export const sourceNodes = async ( }, strapiConfig, ) => { + reporter.info(`gatsby-source-strapi is using Strapi version ${strapiConfig.version || 4}`); + // Cast singleTypes and collectionTypes to empty arrays if they're not defined if (!Array.isArray(strapiConfig.singleTypes)) { strapiConfig.singleTypes = []; @@ -56,7 +53,7 @@ export const sourceNodes = async ( cache, }; - const { unstable_createNodeManifest, createNode } = actions; + const { createNode } = actions; const existingNodes = getNodes().filter( (n) => n.internal.owner === `gatsby-source-strapi` || n.internal.type === "File", @@ -136,8 +133,6 @@ export const sourceNodes = async ( } } - let warnOnceForNoSupport = false; - await cache.set(LAST_FETCHED_KEY, Date.now()); for (const [index, { uid }] of endpoints.entries()) { @@ -147,34 +142,7 @@ export const sourceNodes = async ( for (let entity of data[index]) { const nodes = createNodes(entity, context, uid); - await Promise.all(nodes.map((n) => createNode(n))); - - const nodeType = makeParentNodeName(context.schemas, uid); - - const mainEntryNode = nodes.find((n) => { - return n && n.strapi_id === entity.id && n.internal.type === nodeType; - }); - - const isPreview = process.env.GATSBY_IS_PREVIEW === `true`; - const createNodeManifestIsSupported = typeof unstable_createNodeManifest === `function`; - const shouldCreateNodeManifest = isPreview && createNodeManifestIsSupported && mainEntryNode; - - if (shouldCreateNodeManifest) { - const updatedAt = entity.updatedAt; - const manifestId = `${uid}-${entity.id}-${updatedAt}`; - - unstable_createNodeManifest({ - manifestId, - node: mainEntryNode, - updatedAtUTC: updatedAt, - }); - } else if (isPreview && !createNodeManifestIsSupported && !warnOnceForNoSupport) { - console.warn( - `gatsby-source-strapi: Your version of Gatsby core doesn't support Content Sync (via the unstable_createNodeManifest action). Please upgrade to the latest version to use Content Sync in your site.`, - ); - warnOnceForNoSupport = true; - } } } diff --git a/packages/gatsby-source-strapi/src/helpers.js b/packages/gatsby-source-strapi/src/helpers.js index 880e44eaf..971adf854 100644 --- a/packages/gatsby-source-strapi/src/helpers.js +++ b/packages/gatsby-source-strapi/src/helpers.js @@ -47,9 +47,10 @@ const buildMapFromData = (endpoints, data) => { const nodeType = _.toUpper(`Strapi_${_.snakeCase(singularName)}`); for (let entity of data[index]) { - map[nodeType] = map[nodeType] - ? [...map[nodeType], { strapi_id: entity.id }] - : [{ strapi_id: entity.id }]; + // f.e. components do not have a documentId, allow regular id + // also, support both v5 documentId and v4 id + const strapi_id = entity.documentId || entity.id; + map[nodeType] = map[nodeType] ? [...map[nodeType], { strapi_id }] : [{ strapi_id }]; } } @@ -85,7 +86,7 @@ const getContentTypeSchema = (schemas, ctUID) => { return currentContentTypeSchema; }; -const getEndpoints = ({ collectionTypes, singleTypes }, schemas) => { +const getEndpoints = ({ collectionTypes, singleTypes, version = 4 }, schemas) => { const types = normalizeConfig({ collectionTypes, singleTypes }); const endpoints = schemas @@ -111,6 +112,7 @@ const getEndpoints = ({ collectionTypes, singleTypes }, schemas) => { populate: "*", }, pluginOptions, + version, }; } @@ -129,6 +131,7 @@ const getEndpoints = ({ collectionTypes, singleTypes }, schemas) => { populate: queryParams?.populate || "*", }, pluginOptions, + version, }; }); diff --git a/packages/gatsby-source-strapi/src/normalize.js b/packages/gatsby-source-strapi/src/normalize.js index 4fe1b9b3b..ed0eccda2 100644 --- a/packages/gatsby-source-strapi/src/normalize.js +++ b/packages/gatsby-source-strapi/src/normalize.js @@ -1,3 +1,5 @@ +/*eslint no-undef: "error"*/ + import _ from "lodash"; import { getContentTypeSchema, makeParentNodeName } from "./helpers"; @@ -45,12 +47,13 @@ const prepareRelationNode = (relation, context) => { // } = targetSchema; const nodeType = makeParentNodeName(schemas, targetSchemaUid); - const relationNodeId = createNodeId(`${nodeType}-${relation.id}`); + const strapi_id = relation.documentId || relation.id; // support both v5 and v4 + const relationNodeId = createNodeId(`${nodeType}-${strapi_id}`); const node = { ...relation, id: relationNodeId, - strapi_id: relation.id, + strapi_id, parent: parentNode.id, children: [], internal: { @@ -133,9 +136,13 @@ export const createNodes = (entity, context, uid) => { const { schemas, createNodeId, createContentDigest, getNode } = context; const nodeType = makeParentNodeName(schemas, uid); + // f.e. components do not have a documentId, allow regular id + // also, support both v5 documentId and v4 id + const strapi_id = entity.documentId || entity.id; + let entryNode = { - id: createNodeId(`${nodeType}-${entity.id}`), - strapi_id: entity.id, + id: createNodeId(`${nodeType}-${strapi_id}`), + strapi_id, parent: undefined, children: [], internal: {