├── index.js ├── graphql-diagram.png ├── LICENSE.md ├── package.json ├── tsconfig.json ├── gatsby-node.js └── gatsby-node.ts /index.js: -------------------------------------------------------------------------------- 1 | // no-op 2 | -------------------------------------------------------------------------------- /graphql-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/craftcms/gatsby-source-craft/HEAD/graphql-diagram.png -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Pixel & Tonic, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-source-craft", 3 | "version": "3.0.0", 4 | "description": "Gatsby source plugin for Craft CMS", 5 | "keywords": [ 6 | "gatsby", 7 | "gatsby-plugin", 8 | "gatsby-source-plugin", 9 | "craft", 10 | "cms", 11 | "craftcms" 12 | ], 13 | "homepage": "https://craftcms.com", 14 | "bugs": { 15 | "url": "https://github.com/craftcms/gatsby-source-craft/issues" 16 | }, 17 | "license": "MIT", 18 | "repository": "github:craftcms/gatsby-source-craft", 19 | "scripts": { 20 | "build": "gatsby build", 21 | "develop": "gatsby develop", 22 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"", 23 | "start": "npm run develop", 24 | "serve": "gatsby serve", 25 | "clean": "gatsby clean", 26 | "test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\" && exit 1" 27 | }, 28 | "dependencies": { 29 | "fs-extra": "^9.0.1", 30 | "gatsby-graphql-source-toolkit": "^2.0.0", 31 | "gatsby-source-filesystem": "^4.1.0", 32 | "node-fetch": "^2.6.1", 33 | "p-retry": "^4.6.1" 34 | }, 35 | "devDependencies": { 36 | "@types/fs-extra": "^9.0.13", 37 | "@types/node-fetch": "^2.5.12", 38 | "dotenv": "^8.2.0", 39 | "gatsby": "^4.0.0", 40 | "graphql": "^15.0.0", 41 | "prettier": "2.0.5", 42 | "typescript": "^4.5.5" 43 | }, 44 | "peerDependencies": { 45 | "gatsby": "^4.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | "allowJs": false, /* Allow javascript files to be compiled. */ 11 | "checkJs": false, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const gatsby_source_filesystem_1 = require("gatsby-source-filesystem"); 7 | const p_retry_1 = __importDefault(require("p-retry")); 8 | const fs = require("fs-extra"); 9 | const fetch = require("node-fetch"); 10 | const path = require("path"); 11 | const { print } = require("gatsby/graphql"); 12 | const { sourceAllNodes, sourceNodeChanges, createSchemaCustomization, generateDefaultFragments, compileNodeQueries, buildNodeDefinitions, wrapQueryExecutorWithQueue, loadSchema, } = require("gatsby-graphql-source-toolkit"); 13 | const loadedPluginOptions = { 14 | craftGqlToken: process.env.CRAFTGQL_TOKEN + "", 15 | craftGqlUrl: process.env.CRAFTGQL_URL + "", 16 | concurrency: 10, 17 | debugDir: __dirname + "/.cache/craft-graphql-documents", 18 | fragmentsDir: __dirname + "/.cache/craft-fragments", 19 | typePrefix: "Craft_", 20 | looseInterfaces: false, 21 | sourcingParams: {}, 22 | enabledSites: null, 23 | verbose: false, 24 | retryOptions: { retries: 1 }, 25 | }; 26 | const internalFragmentDir = __dirname + "/.cache/internal-craft-fragments"; 27 | let schema; 28 | let gatsbyNodeTypes; 29 | let sourcingConfig; 30 | let previewToken; 31 | let craftInterfaces = []; 32 | let craftTypesByInterface = {}; 33 | let craftFieldsByInterface = {}; 34 | let craftPrimarySiteId = ''; 35 | let craftEnabledSites = ''; 36 | let remoteConfigVersion = ''; 37 | let lastUpdateTime = ''; 38 | let gatsbyHelperVersion = ''; 39 | let craftGqlTypePrefix = ''; 40 | let craftVersion = ''; 41 | let craftElementIdField = 'sourceId'; 42 | /** 43 | * Fetch the schema 44 | */ 45 | async function getSchema() { 46 | if (!schema) { 47 | schema = await loadSchema(execute); 48 | } 49 | return schema; 50 | } 51 | /** 52 | * Return a list of all possible Gatsby node types 53 | */ 54 | async function getGatsbyNodeTypes(reporter) { 55 | if (!craftVersion.length) { 56 | reporter.error('Unable to source nodes!'); 57 | return ([]); 58 | } 59 | if (gatsbyNodeTypes) { 60 | return gatsbyNodeTypes; 61 | } 62 | const schema = await getSchema(); 63 | gatsbyNodeTypes = []; 64 | const queryResponse = await execute({ 65 | operationName: 'sourceNodeData', 66 | query: `query sourceNodeData { 67 | sourceNodeInformation { 68 | node 69 | list 70 | filterArgument 71 | filterTypeExpression 72 | targetInterface 73 | } 74 | }`, 75 | variables: {}, 76 | additionalHeaders: { 77 | "X-Craft-Gql-Cache": "no-cache" 78 | } 79 | }); 80 | if (!(queryResponse.data && queryResponse.data.sourceNodeInformation)) { 81 | return ([]); 82 | } 83 | const sourceNodeInformation = queryResponse.data.sourceNodeInformation; 84 | const queryMap = {}; 85 | // Loop through returned data and build the query map Craft has provided for us. 86 | for (let nodeInformation of sourceNodeInformation) { 87 | queryMap[nodeInformation.targetInterface] = { 88 | list: nodeInformation.list, 89 | node: nodeInformation.node 90 | }; 91 | if (nodeInformation.filterArgument) { 92 | queryMap[nodeInformation.targetInterface].filterArgument = nodeInformation.filterArgument; 93 | } 94 | if (nodeInformation.filterTypeExpression) { 95 | queryMap[nodeInformation.targetInterface].filterTypeExpression = nodeInformation.filterTypeExpression; 96 | } 97 | craftInterfaces.push(nodeInformation.targetInterface); 98 | } 99 | /** 100 | * Helper function that extracts possible Gatsby nodes by interface name 101 | * @param string ifaceName 102 | * @param callable queryListBuilder 103 | */ 104 | const extractNodesFromInterface = (ifaceName, queryListBuilder) => { 105 | const iface = schema.getType(ifaceName); 106 | if (!iface) { 107 | return []; 108 | } 109 | for (let field of Object.values(iface.getFields())) { 110 | if (craftFieldsByInterface[ifaceName]) { 111 | craftFieldsByInterface[ifaceName].push(field); 112 | } 113 | else { 114 | craftFieldsByInterface[ifaceName] = [field]; 115 | } 116 | } 117 | const canBeDraft = (input) => { 118 | return typeof input === 'object' && input !== null && '_fields' in input && craftElementIdField in input.getFields(); 119 | }; 120 | return schema.getPossibleTypes(iface).map(type => { 121 | if (craftTypesByInterface[ifaceName]) { 122 | craftTypesByInterface[ifaceName].push(type); 123 | } 124 | else { 125 | craftTypesByInterface[ifaceName] = [type]; 126 | } 127 | return ({ 128 | remoteTypeName: type.name, 129 | queries: queryListBuilder(type.name, canBeDraft(type)), 130 | nodeQueryVariables: id => { 131 | var _a; 132 | const idValue = (_a = id.sourceId) !== null && _a !== void 0 ? _a : id.id; 133 | return { 134 | id: idValue, 135 | siteId: id.siteId 136 | }; 137 | } 138 | }); 139 | }); 140 | }; 141 | // prettier-ignore 142 | /** 143 | * Fragment definition helper 144 | * @param string typeName 145 | */ 146 | const fragmentHelper = (typeName, canBeDraft) => { 147 | const fragmentName = '_Craft' + typeName + 'ID_'; 148 | const idProperty = canBeDraft ? craftElementIdField : 'id'; 149 | return { 150 | fragmentName: fragmentName, 151 | fragment: ` 152 | fragment ${fragmentName} on ${typeName} { 153 | __typename 154 | ${idProperty} 155 | siteId 156 | } 157 | ` 158 | }; 159 | }; 160 | if (loadedPluginOptions.enabledSites) { 161 | if (typeof loadedPluginOptions.enabledSites == "object") { 162 | craftEnabledSites = `["${loadedPluginOptions.enabledSites.join('", "')}"]`; 163 | } 164 | else { 165 | craftEnabledSites = `"${loadedPluginOptions.enabledSites}"`; 166 | } 167 | } 168 | else { 169 | craftEnabledSites = `"${craftPrimarySiteId}"`; 170 | } 171 | // For all the mapped queries 172 | for (let [interfaceName, sourceNodeInformation] of Object.entries(queryMap)) { 173 | // extract all the different types for the interfaces 174 | gatsbyNodeTypes.push(...extractNodesFromInterface(interfaceName, (typeName, canBeDraft) => { 175 | let queries = ''; 176 | let fragmentInfo = fragmentHelper(typeName, canBeDraft); 177 | queries = fragmentInfo.fragment; 178 | // and define queries for the concrete type 179 | if (sourceNodeInformation.node) { 180 | queries += `query NODE_${typeName} { ${sourceNodeInformation.node}(id: $id siteId: $siteId status: null) { ... ${fragmentInfo.fragmentName} } } 181 | `; 182 | } 183 | let typeFilter = ''; 184 | if (sourceNodeInformation.filterArgument) { 185 | let regexp = new RegExp(sourceNodeInformation.filterTypeExpression); 186 | const matches = typeName.match(regexp); 187 | if (matches && matches[1]) { 188 | typeFilter = sourceNodeInformation.filterArgument + ': "' + matches[1] + '"'; 189 | } 190 | } 191 | // Add sourcing parameters defined by user to the sourcing queries 192 | let configuredParameters = {}; 193 | // Interfaces first 194 | if (interfaceName in loadedPluginOptions.sourcingParams) { 195 | configuredParameters = Object.assign(configuredParameters, loadedPluginOptions.sourcingParams[interfaceName]); 196 | } 197 | // More specific implementations next 198 | if (typeName in loadedPluginOptions.sourcingParams) { 199 | configuredParameters = Object.assign(configuredParameters, loadedPluginOptions.sourcingParams[typeName]); 200 | } 201 | // Convert all of that to a string 202 | let configuredParameterString = ''; 203 | for (const [key, value] of Object.entries(configuredParameters)) { 204 | configuredParameterString += `${key}: ${value} `; 205 | } 206 | queries += `query LIST_${typeName} { ${sourceNodeInformation.list}(${typeFilter} limit: $limit, offset: $offset site: ${craftEnabledSites} ${configuredParameterString}) { ... ${fragmentInfo.fragmentName} } } 207 | `; 208 | return queries; 209 | })); 210 | } 211 | return (gatsbyNodeTypes); 212 | } 213 | /** 214 | * Write default fragments to the disk. 215 | */ 216 | async function writeDefaultFragments(reporter) { 217 | const defaultFragments = generateDefaultFragments({ 218 | schema: await getSchema(), 219 | gatsbyNodeTypes: await getGatsbyNodeTypes(reporter), 220 | }); 221 | await fs.ensureDir(internalFragmentDir); 222 | for (const [remoteTypeName, fragment] of defaultFragments) { 223 | const filePath = path.join(internalFragmentDir, `${remoteTypeName}.graphql`); 224 | if (!fs.existsSync(filePath)) { 225 | await fs.writeFile(filePath, fragment); 226 | } 227 | } 228 | } 229 | async function addExtraFragments(reporter) { 230 | const fragmentDir = loadedPluginOptions.fragmentsDir; 231 | const fragments = await fs.readdir(fragmentDir); 232 | const mandatoryFragments = { 233 | ensureRemoteId: `fragment RequiredEntryFields on ${craftGqlTypePrefix}EntryInterface { id }` 234 | }; 235 | // Add mandatory fragments 236 | for (let [fragmentName, fragmentBody] of Object.entries(mandatoryFragments)) { 237 | fragmentName += '.graphql'; 238 | const filePath = path.join(internalFragmentDir, fragmentName); 239 | fs.writeFile(filePath, fragmentBody); 240 | } 241 | reporter.info("Found " + fragments.length + " additional fragments"); 242 | // Look at the configured folder 243 | // Otherwise, copy it to the internal folder, maybe overwriting a default fragment 244 | for (const fragmentFile of fragments) { 245 | const extraFile = path.join(fragmentDir, fragmentFile); 246 | const existingFile = path.join(internalFragmentDir, fragmentFile); 247 | const stats = fs.statSync(extraFile); 248 | const fileSizeInBytes = stats["size"]; 249 | if (fs.existsSync(existingFile)) { 250 | reporter.info("Overwriting the " + fragmentFile + " fragment"); 251 | } 252 | else { 253 | reporter.info("Adding " + fragmentFile + " to additional fragments"); 254 | } 255 | fs.copyFileSync(extraFile, existingFile); 256 | } 257 | } 258 | /** 259 | * Collect fragments from the disk. 260 | */ 261 | async function collectFragments() { 262 | const customFragments = []; 263 | for (const fileName of await fs.readdir(internalFragmentDir)) { 264 | if (/.graphql$/.test(fileName)) { 265 | const filePath = path.join(internalFragmentDir, fileName); 266 | const fragment = await fs.readFile(filePath); 267 | customFragments.push(fragment.toString()); 268 | } 269 | } 270 | return customFragments; 271 | } 272 | /** 273 | * Write the compiled sourcing queries to the disk 274 | * @param nodeDocs 275 | */ 276 | async function writeCompiledQueries(nodeDocs) { 277 | // @ts-ignore 278 | for (const [remoteTypeName, document] of nodeDocs) { 279 | await fs.writeFile(loadedPluginOptions.debugDir + `/${remoteTypeName}.graphql`, print(document)); 280 | } 281 | } 282 | /** 283 | * Execute a GraphQL query 284 | * @param operation 285 | */ 286 | async function execute(operation) { 287 | var _a, _b; 288 | let { operationName, query, variables = {}, additionalHeaders = {} } = operation; 289 | const headers = Object.assign(Object.assign(Object.assign({}, ((_b = (_a = loadedPluginOptions.fetchOptions) === null || _a === void 0 ? void 0 : _a.headers) !== null && _b !== void 0 ? _b : {})), { "Content-Type": "application/json", Authorization: `Bearer ${loadedPluginOptions.craftGqlToken}` }), additionalHeaders); 290 | // Set the token, if it exists 291 | if (previewToken) { 292 | headers["X-Craft-Token"] = previewToken; 293 | } 294 | const res = await p_retry_1.default(() => fetch(loadedPluginOptions.craftGqlUrl, Object.assign(Object.assign({}, loadedPluginOptions.fetchOptions), { method: "POST", body: JSON.stringify({ query, variables, operationName }), headers })), loadedPluginOptions.retryOptions); 295 | // Aaaand remove the token for subsequent requests 296 | previewToken = null; 297 | return await res.json(); 298 | } 299 | async function initializePlugin(pluginOptions, gatsbyApi) { 300 | var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o; 301 | // Initialize the plugin options 302 | loadedPluginOptions.craftGqlUrl = (_a = pluginOptions.craftGqlUrl) !== null && _a !== void 0 ? _a : loadedPluginOptions.craftGqlUrl; 303 | loadedPluginOptions.craftGqlToken = (_b = pluginOptions.craftGqlToken) !== null && _b !== void 0 ? _b : loadedPluginOptions.craftGqlToken; 304 | loadedPluginOptions.concurrency = (_c = pluginOptions.concurrency) !== null && _c !== void 0 ? _c : loadedPluginOptions.concurrency; 305 | loadedPluginOptions.debugDir = (_d = pluginOptions.debugDir) !== null && _d !== void 0 ? _d : loadedPluginOptions.debugDir; 306 | loadedPluginOptions.fragmentsDir = (_e = pluginOptions.fragmentsDir) !== null && _e !== void 0 ? _e : loadedPluginOptions.fragmentsDir; 307 | loadedPluginOptions.typePrefix = (_f = pluginOptions.typePrefix) !== null && _f !== void 0 ? _f : loadedPluginOptions.typePrefix; 308 | loadedPluginOptions.looseInterfaces = (_g = pluginOptions.looseInterfaces) !== null && _g !== void 0 ? _g : loadedPluginOptions.looseInterfaces; 309 | loadedPluginOptions.sourcingParams = (_h = pluginOptions.sourcingParams) !== null && _h !== void 0 ? _h : loadedPluginOptions.sourcingParams; 310 | loadedPluginOptions.enabledSites = (_j = pluginOptions.enabledSites) !== null && _j !== void 0 ? _j : loadedPluginOptions.enabledSites; 311 | loadedPluginOptions.verbose = (_k = pluginOptions.verbose) !== null && _k !== void 0 ? _k : loadedPluginOptions.verbose; 312 | loadedPluginOptions.fetchOptions = (_l = pluginOptions.fetchOptions) !== null && _l !== void 0 ? _l : loadedPluginOptions.fetchOptions; 313 | loadedPluginOptions.retryOptions = (_m = pluginOptions.retryOptions) !== null && _m !== void 0 ? _m : loadedPluginOptions.retryOptions; 314 | // Make sure the folders exists 315 | await fs.ensureDir(loadedPluginOptions.debugDir); 316 | await fs.ensureDir(loadedPluginOptions.fragmentsDir); 317 | // Fetch the meta data 318 | const reporter = gatsbyApi.reporter; 319 | reporter.info("Querying for Craft state."); 320 | const schema = await getSchema(); 321 | const queries = (_o = schema.getQueryType()) === null || _o === void 0 ? void 0 : _o.getFields(); 322 | if (!queries) { 323 | reporter.info("Unable to fetch Craft schema."); 324 | return; 325 | } 326 | // Check if Craft endpoint has Gatsby plugin installed and enabled. 327 | if (!queries.sourceNodeInformation) { 328 | reporter.info("Gatsby Helper not found on target Craft site."); 329 | return; 330 | } 331 | if (!queries.craftVersion) { 332 | reporter.info("Gatsby Helper plugin must be at least version 1.1.0 or greater."); 333 | } 334 | const { data } = await execute({ 335 | operationName: 'craftState', 336 | query: `query craftState { 337 | configVersion 338 | lastUpdateTime 339 | primarySiteId 340 | gatsbyHelperVersion 341 | gqlTypePrefix 342 | craftVersion 343 | }`, 344 | variables: {}, 345 | additionalHeaders: { 346 | "X-Craft-Gql-Cache": "no-cache" 347 | } 348 | }); 349 | remoteConfigVersion = data.configVersion; 350 | lastUpdateTime = data.lastUpdateTime; 351 | craftGqlTypePrefix = data.gqlTypePrefix; 352 | gatsbyHelperVersion = data.gatsbyHelperVersion; 353 | craftPrimarySiteId = data.primarySiteId; 354 | craftVersion = data.craftVersion; 355 | // Avoid deprecation errors 356 | if (craftVersion >= '3.7.0') { 357 | console.log('Switch to canonical?'); 358 | craftElementIdField = 'canonicalId'; 359 | } 360 | reporter.info(`Craft v${craftVersion}, running Helper plugin v${gatsbyHelperVersion}`); 361 | // Make sure the fragments exist 362 | await ensureFragmentsExist(reporter); 363 | } 364 | exports.onPluginInit = async (gatsbyApi, pluginOptions) => { 365 | await initializePlugin(pluginOptions, gatsbyApi); 366 | }; 367 | exports.createSchemaCustomization = async (gatsbyApi) => { 368 | const config = await getSourcingConfig(gatsbyApi); 369 | const { createTypes } = gatsbyApi.actions; 370 | let typeDefs = ''; 371 | for (let craftInterface of craftInterfaces) { 372 | let extraFields = {}; 373 | let extraFieldsAsString = ''; 374 | let redefineTypes = ''; 375 | const extractFieldType = (field, onlyNullable) => { 376 | const fieldName = field.name; 377 | const skippedTypes = ['id', 'parent', 'children', 'next', 'prev']; 378 | // If skipped type or begins with an underscore 379 | if (skippedTypes.includes(fieldName) || fieldName.charAt(0) === '_') { 380 | return false; 381 | } 382 | let fieldType = field.type.toString(); 383 | // If only nullable and is non-nullable 384 | if (onlyNullable && fieldType.slice(-1) == '!') { 385 | return false; 386 | } 387 | // If any arguments are required, can't have it. 388 | for (let fieldArgument of field.args) { 389 | if (fieldArgument.type.toString().slice(-1) == '!') { 390 | return false; 391 | } 392 | } 393 | // Convert Craft's DateTime to Gatsby's Date. 394 | fieldType = fieldType.replace(new RegExp(craftGqlTypePrefix + 'DateTime'), 'JSON'); 395 | if (fieldType.match(/(Int|Float|String|Boolean|ID|JSON)(\]|!|$)/)) { 396 | return fieldType; 397 | } 398 | return fieldType.replace(/^([^a-z]+)?([a-z_]+)([^a-z]+)?$/i, '$1' + loadedPluginOptions.typePrefix + '$2$3'); 399 | }; 400 | // For all interfaces 401 | if (craftTypesByInterface[craftInterface]) { 402 | if (loadedPluginOptions.looseInterfaces) { 403 | // Collect all fields across all implementations of the interface if loose interfaces are enabled 404 | for (let gqlType of craftTypesByInterface[craftInterface]) { 405 | for (let field of Object.values(gqlType.getFields())) { 406 | let extractedType = extractFieldType(field, true); 407 | if (extractedType) { 408 | extraFields[field.name] = extractedType; 409 | } 410 | } 411 | } 412 | } 413 | else if (craftFieldsByInterface[craftInterface]) { 414 | // Otherwise just collect the interface fields 415 | for (let field of Object.values(craftFieldsByInterface[craftInterface])) { 416 | let extractedType = extractFieldType(field, false); 417 | if (extractedType) { 418 | extraFields[field.name] = extractedType; 419 | } 420 | } 421 | } 422 | // Create a string of all the fields we found. 423 | for (let [fieldName, fieldType] of Object.entries(extraFields)) { 424 | extraFieldsAsString += `${fieldName}: ${fieldType} 425 | `; 426 | } 427 | // If loose interfaces are enabled, redefine the types, too. 428 | if (loadedPluginOptions.looseInterfaces) { 429 | // And now redefine all the implementations to have all the fields. 430 | for (let gqlType of craftTypesByInterface[craftInterface]) { 431 | redefineTypes += `type ${loadedPluginOptions.typePrefix}${gqlType.name} { 432 | id: ID! 433 | ${extraFieldsAsString} 434 | }`; 435 | } 436 | } 437 | } 438 | typeDefs += ` 439 | interface ${loadedPluginOptions.typePrefix}${craftInterface} implements Node { 440 | id: ID! 441 | ${extraFieldsAsString} 442 | } 443 | 444 | ${redefineTypes} 445 | `; 446 | } 447 | createTypes(typeDefs); 448 | await createSchemaCustomization(config); 449 | }; 450 | // @ts-ignore 451 | // Add `localFile` nodes to assets. 452 | exports.createResolvers = async ({ createResolvers, intermediateSchema, actions, cache, createNodeId, store, reporter }) => { 453 | const { createNode } = actions; 454 | const ifaceName = `${loadedPluginOptions.typePrefix + craftGqlTypePrefix}AssetInterface`; 455 | const iface = intermediateSchema.getType(ifaceName); 456 | if (iface) { 457 | const possibleTypes = intermediateSchema.getPossibleTypes(iface); 458 | const resolvers = {}; 459 | for (const assetType of possibleTypes) { 460 | resolvers[assetType.name] = { 461 | localFile: { 462 | type: `File`, 463 | async resolve(source) { 464 | if (source.url) { 465 | return await gatsby_source_filesystem_1.createRemoteFileNode({ 466 | url: encodeURI(source.url), 467 | store, 468 | cache, 469 | createNode, 470 | createNodeId, 471 | reporter 472 | }); 473 | } 474 | }, 475 | }, 476 | }; 477 | } 478 | createResolvers(resolvers); 479 | } 480 | }; 481 | // Source the actual Gatsby nodes 482 | exports.sourceNodes = async (gatsbyApi) => { 483 | const { cache, reporter, webhookBody } = gatsbyApi; 484 | const config = await getSourcingConfig(gatsbyApi); 485 | // If this is a webhook call 486 | if (webhookBody && typeof webhookBody == "object" && Object.keys(webhookBody).length) { 487 | reporter.info("Processing webhook."); 488 | const nodeEvent = (webhookBody) => { 489 | var _a; 490 | const { operation, typeName, id, siteId } = webhookBody; 491 | let eventName = ''; 492 | switch (operation) { 493 | case 'delete': 494 | eventName = 'DELETE'; 495 | break; 496 | case 'update': 497 | eventName = 'UPDATE'; 498 | break; 499 | } 500 | previewToken = (_a = webhookBody.token) !== null && _a !== void 0 ? _a : null; 501 | // Create the node event 502 | return { 503 | eventName, 504 | remoteTypeName: typeName, 505 | remoteId: { id, __typename: typeName, siteId }, 506 | }; 507 | }; 508 | // And source it 509 | await sourceNodeChanges(config, { 510 | nodeEvents: [nodeEvent(webhookBody)], 511 | }); 512 | return; 513 | } 514 | const localConfigVersion = (await cache.get(`CRAFT_CONFIG_VERSION`)) || ''; 515 | const localContentUpdateTime = (await cache.get(`CRAFT_LAST_CONTENT_UPDATE`)) || ''; 516 | // If either project config changed or we don't have cached content, source it all 517 | if (remoteConfigVersion !== localConfigVersion || !localContentUpdateTime) { 518 | reporter.info("Cached content is unavailable or outdated, sourcing _all_ nodes."); 519 | await sourceAllNodes(config); 520 | } 521 | else { 522 | reporter.info(`Craft config version has not changed since last sourcing. Checking for content changes since "${localContentUpdateTime}".`); 523 | // otherwise, check for changed and deleted content. 524 | const { data } = await execute({ 525 | operationName: 'nodeChanges', 526 | query: `query nodeChanges { 527 | nodesUpdatedSince (since: "${localContentUpdateTime}" site: ${craftEnabledSites}) { nodeId nodeType siteId} 528 | nodesDeletedSince (since: "${localContentUpdateTime}") { nodeId nodeType siteId} 529 | }`, 530 | variables: {}, 531 | additionalHeaders: { 532 | "X-Craft-Gql-Cache": "no-cache" 533 | } 534 | }); 535 | const updatedNodes = data.nodesUpdatedSince; 536 | const deletedNodes = data.nodesDeletedSince; 537 | // Create the sourcing node events 538 | const nodeEvents = [ 539 | ...updatedNodes.map(entry => { 540 | return { 541 | eventName: 'UPDATE', 542 | remoteTypeName: entry.nodeType, 543 | remoteId: { __typename: entry.nodeType, id: entry.nodeId, siteId: entry.siteId } 544 | }; 545 | }), 546 | ...deletedNodes.map(entry => { 547 | return { 548 | eventName: 'DELETE', 549 | remoteTypeName: entry.nodeType, 550 | remoteId: { __typename: entry.nodeType, id: entry.nodeId, siteId: entry.siteId } 551 | }; 552 | }) 553 | ]; 554 | if (nodeEvents.length) { 555 | reporter.info("Sourcing changes for " + nodeEvents.length + " nodes."); 556 | } 557 | else { 558 | reporter.info("No content changes found."); 559 | } 560 | // And source, if needed 561 | await sourceNodeChanges(config, { nodeEvents }); 562 | } 563 | await cache.set(`CRAFT_CONFIG_VERSION`, remoteConfigVersion); 564 | await cache.set(`CRAFT_LAST_CONTENT_UPDATE`, lastUpdateTime); 565 | }; 566 | async function getSourcingConfig(gatsbyApi) { 567 | if (sourcingConfig) { 568 | return sourcingConfig; 569 | } 570 | const schema = await getSchema(); 571 | const gatsbyNodeTypes = await getGatsbyNodeTypes(gatsbyApi.reporter); 572 | const documents = await compileNodeQueries({ 573 | schema, 574 | gatsbyNodeTypes, 575 | customFragments: await collectFragments(), 576 | }); 577 | await writeCompiledQueries(documents); 578 | return (sourcingConfig = { 579 | gatsbyApi, 580 | schema, 581 | gatsbyNodeDefs: buildNodeDefinitions({ gatsbyNodeTypes, documents }), 582 | gatsbyTypePrefix: loadedPluginOptions.typePrefix, 583 | execute: wrapQueryExecutorWithQueue(execute, { concurrency: loadedPluginOptions.concurrency }), 584 | verbose: loadedPluginOptions.verbose, 585 | }); 586 | } 587 | async function ensureFragmentsExist(reporter) { 588 | reporter.info("Clearing previous fragments."); 589 | await fs.remove(internalFragmentDir, { recursive: true }); 590 | reporter.info("Writing default fragments."); 591 | await writeDefaultFragments(reporter); 592 | await addExtraFragments(reporter); 593 | } 594 | -------------------------------------------------------------------------------- /gatsby-node.ts: -------------------------------------------------------------------------------- 1 | import {GraphQLSchema} from "graphql"; 2 | import { 3 | GraphQLField, 4 | GraphQLInterfaceType, 5 | GraphQLObjectType, 6 | } from "graphql/type/definition"; 7 | import {IGatsbyNodeConfig, IGatsbyNodeDefinition, ISourcingConfig} from "gatsby-graphql-source-toolkit/dist/types"; 8 | import { CreateResolversArgs, NodePluginArgs, Reporter } from 'gatsby'; 9 | import {createRemoteFileNode} from "gatsby-source-filesystem"; 10 | import { RequestInit } from "node-fetch"; 11 | import pRetry, { Options as RetryOptions } from "p-retry"; 12 | 13 | type SourcePluginOptions = { 14 | craftGqlUrl: string, 15 | craftGqlToken: string, 16 | concurrency: number, 17 | debugDir: string, 18 | fragmentsDir: string, 19 | typePrefix: string, 20 | looseInterfaces: boolean, 21 | sourcingParams: { [key: string]: { [key:string] : string}}, 22 | enabledSites: string|[string]|null, 23 | verbose: boolean; 24 | fetchOptions?: Omit & { 25 | headers?: { [key: string]: string }; 26 | }; 27 | retryOptions: RetryOptions; 28 | } 29 | 30 | type ModifiedNodeInfo = { 31 | nodeId: number, 32 | nodeType: string, 33 | siteId: number, 34 | } 35 | 36 | type WebhookBody = { 37 | operation: string, 38 | typeName: string, 39 | id: number, 40 | siteId: number, 41 | token?: string 42 | } 43 | 44 | const fs = require("fs-extra") 45 | const fetch = require("node-fetch") 46 | const path = require("path") 47 | const {print} = require("gatsby/graphql") 48 | const { 49 | sourceAllNodes, 50 | sourceNodeChanges, 51 | createSchemaCustomization, 52 | generateDefaultFragments, 53 | compileNodeQueries, 54 | buildNodeDefinitions, 55 | wrapQueryExecutorWithQueue, 56 | loadSchema, 57 | } = require("gatsby-graphql-source-toolkit") 58 | 59 | const loadedPluginOptions: SourcePluginOptions = { 60 | craftGqlToken: process.env.CRAFTGQL_TOKEN + "", 61 | craftGqlUrl: process.env.CRAFTGQL_URL + "", 62 | concurrency: 10, 63 | debugDir: __dirname + "/.cache/craft-graphql-documents", 64 | fragmentsDir: __dirname + "/.cache/craft-fragments", 65 | typePrefix: "Craft_", 66 | looseInterfaces: false, 67 | sourcingParams: {}, 68 | enabledSites: null, 69 | verbose: false, 70 | retryOptions: { retries: 1 }, 71 | }; 72 | 73 | const internalFragmentDir = __dirname + "/.cache/internal-craft-fragments"; 74 | 75 | let schema: GraphQLSchema; 76 | let gatsbyNodeTypes: IGatsbyNodeConfig[]; 77 | let sourcingConfig: ISourcingConfig & { verbose: boolean }; 78 | let previewToken: string|null; 79 | let craftInterfaces: string[] = []; 80 | let craftTypesByInterface: { [key: string]: [GraphQLObjectType] } = {}; 81 | let craftFieldsByInterface: { [key: string]: [GraphQLField] } = {}; 82 | 83 | let craftPrimarySiteId = ''; 84 | let craftEnabledSites = ''; 85 | 86 | let remoteConfigVersion = ''; 87 | let lastUpdateTime = ''; 88 | let gatsbyHelperVersion = ''; 89 | let craftGqlTypePrefix = ''; 90 | let craftVersion = ''; 91 | 92 | let craftElementIdField = 'sourceId'; 93 | 94 | /** 95 | * Fetch the schema 96 | */ 97 | async function getSchema() { 98 | if (!schema) { 99 | schema = await loadSchema(execute) 100 | } 101 | 102 | return schema; 103 | } 104 | 105 | /** 106 | * Return a list of all possible Gatsby node types 107 | */ 108 | async function getGatsbyNodeTypes(reporter: Reporter) { 109 | if (!craftVersion.length) { 110 | reporter.error('Unable to source nodes!'); 111 | return ([]); 112 | } 113 | 114 | if (gatsbyNodeTypes) { 115 | return gatsbyNodeTypes; 116 | } 117 | 118 | const schema = await getSchema(); 119 | 120 | gatsbyNodeTypes = []; 121 | 122 | const queryResponse = await execute({ 123 | operationName: 'sourceNodeData', 124 | query: `query sourceNodeData { 125 | sourceNodeInformation { 126 | node 127 | list 128 | filterArgument 129 | filterTypeExpression 130 | targetInterface 131 | } 132 | }`, 133 | variables: {}, 134 | additionalHeaders: { 135 | "X-Craft-Gql-Cache": "no-cache" 136 | } 137 | }); 138 | 139 | if (!(queryResponse.data && queryResponse.data.sourceNodeInformation)) { 140 | return ([]); 141 | } 142 | 143 | 144 | const sourceNodeInformation = queryResponse.data.sourceNodeInformation; 145 | const queryMap: { [key: string]: { list: string, node: string, filterArgument?: string, filterTypeExpression?: string } } = {}; 146 | 147 | // Loop through returned data and build the query map Craft has provided for us. 148 | for (let nodeInformation of sourceNodeInformation) { 149 | queryMap[nodeInformation.targetInterface] = { 150 | list: nodeInformation.list, 151 | node: nodeInformation.node 152 | }; 153 | 154 | if (nodeInformation.filterArgument) { 155 | queryMap[nodeInformation.targetInterface].filterArgument = nodeInformation.filterArgument; 156 | } 157 | 158 | if (nodeInformation.filterTypeExpression) { 159 | queryMap[nodeInformation.targetInterface].filterTypeExpression = nodeInformation.filterTypeExpression; 160 | } 161 | 162 | craftInterfaces.push(nodeInformation.targetInterface); 163 | } 164 | 165 | /** 166 | * Helper function that extracts possible Gatsby nodes by interface name 167 | * @param string ifaceName 168 | * @param callable queryListBuilder 169 | */ 170 | const extractNodesFromInterface = (ifaceName: string, queryListBuilder: (type: string, canBeDraft: boolean) => string): IGatsbyNodeConfig[] => { 171 | const iface = schema.getType(ifaceName) as GraphQLInterfaceType; 172 | 173 | if (!iface) { 174 | return []; 175 | } 176 | 177 | for (let field of Object.values(iface.getFields())) { 178 | if (craftFieldsByInterface[ifaceName]) { 179 | craftFieldsByInterface[ifaceName].push(field); 180 | } else { 181 | craftFieldsByInterface[ifaceName] = [field]; 182 | } 183 | } 184 | 185 | const canBeDraft = (input: unknown): boolean => { 186 | return typeof input === 'object' && input !== null && '_fields' in input && craftElementIdField in (input as GraphQLObjectType).getFields(); 187 | } 188 | 189 | return schema.getPossibleTypes(iface).map(type => { 190 | if (craftTypesByInterface[ifaceName]) { 191 | craftTypesByInterface[ifaceName].push(type); 192 | } else { 193 | craftTypesByInterface[ifaceName] = [type]; 194 | } 195 | 196 | return ({ 197 | remoteTypeName: type.name, 198 | queries: queryListBuilder(type.name, canBeDraft(type)), 199 | nodeQueryVariables: id => { 200 | const idValue = id.sourceId ?? id.id; 201 | return { 202 | id: idValue, 203 | siteId: id.siteId 204 | } 205 | } 206 | }) 207 | }); 208 | } 209 | 210 | // prettier-ignore 211 | /** 212 | * Fragment definition helper 213 | * @param string typeName 214 | */ 215 | const fragmentHelper = (typeName: string, canBeDraft: boolean): { fragmentName: string, fragment: string } => { 216 | const fragmentName = '_Craft' + typeName + 'ID_'; 217 | const idProperty = canBeDraft ? craftElementIdField : 'id'; 218 | return { 219 | fragmentName: fragmentName, 220 | fragment: ` 221 | fragment ${fragmentName} on ${typeName} { 222 | __typename 223 | ${idProperty} 224 | siteId 225 | } 226 | ` 227 | }; 228 | }; 229 | 230 | if (loadedPluginOptions.enabledSites) { 231 | if (typeof loadedPluginOptions.enabledSites == "object") { 232 | craftEnabledSites = `["${loadedPluginOptions.enabledSites.join('", "')}"]`; 233 | } else { 234 | craftEnabledSites = `"${loadedPluginOptions.enabledSites}"`; 235 | } 236 | } else { 237 | craftEnabledSites = `"${craftPrimarySiteId}"`; 238 | } 239 | 240 | 241 | // For all the mapped queries 242 | for (let [interfaceName, sourceNodeInformation] of Object.entries(queryMap)) { 243 | // extract all the different types for the interfaces 244 | gatsbyNodeTypes.push(...extractNodesFromInterface(interfaceName, (typeName, canBeDraft) => { 245 | let queries = ''; 246 | let fragmentInfo = fragmentHelper(typeName, canBeDraft); 247 | 248 | queries = fragmentInfo.fragment; 249 | 250 | // and define queries for the concrete type 251 | if (sourceNodeInformation.node) { 252 | queries += `query NODE_${typeName} { ${sourceNodeInformation.node}(id: $id siteId: $siteId status: null) { ... ${fragmentInfo.fragmentName} } } 253 | `; 254 | } 255 | 256 | let typeFilter = ''; 257 | 258 | if (sourceNodeInformation.filterArgument) { 259 | let regexp = new RegExp(sourceNodeInformation.filterTypeExpression as string); 260 | const matches = typeName.match(regexp); 261 | 262 | 263 | if (matches && matches[1]) { 264 | typeFilter = sourceNodeInformation.filterArgument + ': "' + matches[1] + '"'; 265 | } 266 | } 267 | 268 | // Add sourcing parameters defined by user to the sourcing queries 269 | let configuredParameters = {}; 270 | 271 | // Interfaces first 272 | if (interfaceName in loadedPluginOptions.sourcingParams) { 273 | configuredParameters = Object.assign(configuredParameters, loadedPluginOptions.sourcingParams[interfaceName]); 274 | } 275 | 276 | // More specific implementations next 277 | if (typeName in loadedPluginOptions.sourcingParams) { 278 | configuredParameters = Object.assign(configuredParameters, loadedPluginOptions.sourcingParams[typeName]); 279 | } 280 | 281 | // Convert all of that to a string 282 | let configuredParameterString = ''; 283 | for (const [key, value] of Object.entries(configuredParameters)) { 284 | configuredParameterString += `${key}: ${value} `; 285 | } 286 | 287 | queries += `query LIST_${typeName} { ${sourceNodeInformation.list}(${typeFilter} limit: $limit, offset: $offset site: ${craftEnabledSites} ${configuredParameterString}) { ... ${fragmentInfo.fragmentName} } } 288 | `; 289 | 290 | return queries; 291 | })); 292 | } 293 | 294 | return (gatsbyNodeTypes); 295 | } 296 | 297 | /** 298 | * Write default fragments to the disk. 299 | */ 300 | async function writeDefaultFragments(reporter: Reporter) { 301 | const defaultFragments = generateDefaultFragments({ 302 | schema: await getSchema(), 303 | gatsbyNodeTypes: await getGatsbyNodeTypes(reporter), 304 | }) 305 | 306 | await fs.ensureDir(internalFragmentDir) 307 | 308 | for (const [remoteTypeName, fragment] of defaultFragments) { 309 | const filePath = path.join(internalFragmentDir, `${remoteTypeName}.graphql`) 310 | if (!fs.existsSync(filePath)) { 311 | await fs.writeFile(filePath, fragment) 312 | } 313 | } 314 | } 315 | 316 | async function addExtraFragments (reporter: Reporter) { 317 | const fragmentDir = loadedPluginOptions.fragmentsDir; 318 | const fragments = await fs.readdir(fragmentDir); 319 | 320 | const mandatoryFragments = { 321 | ensureRemoteId: `fragment RequiredEntryFields on ${craftGqlTypePrefix}EntryInterface { id }` 322 | } 323 | 324 | // Add mandatory fragments 325 | for (let [fragmentName, fragmentBody] of Object.entries(mandatoryFragments)) { 326 | fragmentName += '.graphql'; 327 | const filePath = path.join(internalFragmentDir, fragmentName); 328 | fs.writeFile(filePath, fragmentBody); 329 | } 330 | 331 | reporter.info("Found " + fragments.length + " additional fragments") 332 | 333 | // Look at the configured folder 334 | // Otherwise, copy it to the internal folder, maybe overwriting a default fragment 335 | for (const fragmentFile of fragments) { 336 | const extraFile = path.join(fragmentDir, fragmentFile); 337 | const existingFile = path.join(internalFragmentDir, fragmentFile); 338 | 339 | const stats = fs.statSync(extraFile) 340 | const fileSizeInBytes = stats["size"] 341 | 342 | if (fs.existsSync(existingFile)) { 343 | reporter.info("Overwriting the " + fragmentFile + " fragment") 344 | } else { 345 | reporter.info("Adding " + fragmentFile + " to additional fragments") 346 | } 347 | 348 | fs.copyFileSync(extraFile, existingFile); 349 | 350 | } 351 | } 352 | 353 | /** 354 | * Collect fragments from the disk. 355 | */ 356 | async function collectFragments() { 357 | const customFragments = [] 358 | for (const fileName of await fs.readdir(internalFragmentDir)) { 359 | if (/.graphql$/.test(fileName)) { 360 | const filePath = path.join(internalFragmentDir, fileName) 361 | const fragment = await fs.readFile(filePath) 362 | customFragments.push(fragment.toString()) 363 | } 364 | } 365 | return customFragments 366 | } 367 | 368 | /** 369 | * Write the compiled sourcing queries to the disk 370 | * @param nodeDocs 371 | */ 372 | async function writeCompiledQueries(nodeDocs: IGatsbyNodeDefinition[]) { 373 | // @ts-ignore 374 | for (const [remoteTypeName, document] of nodeDocs) { 375 | await fs.writeFile(loadedPluginOptions.debugDir + `/${remoteTypeName}.graphql`, print(document)) 376 | } 377 | } 378 | 379 | /** 380 | * Execute a GraphQL query 381 | * @param operation 382 | */ 383 | async function execute(operation: { operationName: string, query: string, variables: object, additionalHeaders: object }) { 384 | let {operationName, query, variables = {}, additionalHeaders = {} } = operation; 385 | 386 | const headers: { [key: string]: string } = { 387 | ...(loadedPluginOptions.fetchOptions?.headers ?? {}), 388 | "Content-Type": "application/json", 389 | Authorization: `Bearer ${loadedPluginOptions.craftGqlToken}`, 390 | ...additionalHeaders, 391 | }; 392 | 393 | // Set the token, if it exists 394 | if (previewToken) { 395 | headers["X-Craft-Token"] = previewToken; 396 | } 397 | 398 | const res = await pRetry( 399 | () => 400 | fetch(loadedPluginOptions.craftGqlUrl, { 401 | ...loadedPluginOptions.fetchOptions, 402 | method: "POST", 403 | body: JSON.stringify({ query, variables, operationName }), 404 | headers, 405 | }), 406 | loadedPluginOptions.retryOptions 407 | ); 408 | 409 | // Aaaand remove the token for subsequent requests 410 | previewToken = null; 411 | 412 | return await res.json() 413 | } 414 | 415 | async function initializePlugin(pluginOptions: SourcePluginOptions, gatsbyApi: NodePluginArgs) 416 | { 417 | // Initialize the plugin options 418 | loadedPluginOptions.craftGqlUrl = pluginOptions.craftGqlUrl ?? loadedPluginOptions.craftGqlUrl; 419 | loadedPluginOptions.craftGqlToken = pluginOptions.craftGqlToken ?? loadedPluginOptions.craftGqlToken; 420 | loadedPluginOptions.concurrency = pluginOptions.concurrency ?? loadedPluginOptions.concurrency; 421 | loadedPluginOptions.debugDir = pluginOptions.debugDir ?? loadedPluginOptions.debugDir; 422 | loadedPluginOptions.fragmentsDir = pluginOptions.fragmentsDir ?? loadedPluginOptions.fragmentsDir; 423 | loadedPluginOptions.typePrefix = pluginOptions.typePrefix ?? loadedPluginOptions.typePrefix; 424 | loadedPluginOptions.looseInterfaces = pluginOptions.looseInterfaces ?? loadedPluginOptions.looseInterfaces; 425 | loadedPluginOptions.sourcingParams = pluginOptions.sourcingParams ?? loadedPluginOptions.sourcingParams; 426 | loadedPluginOptions.enabledSites = pluginOptions.enabledSites ?? loadedPluginOptions.enabledSites; 427 | loadedPluginOptions.verbose = pluginOptions.verbose ?? loadedPluginOptions.verbose; 428 | loadedPluginOptions.fetchOptions = pluginOptions.fetchOptions ?? loadedPluginOptions.fetchOptions; 429 | loadedPluginOptions.retryOptions = pluginOptions.retryOptions ?? loadedPluginOptions.retryOptions; 430 | 431 | // Make sure the folders exists 432 | await fs.ensureDir(loadedPluginOptions.debugDir) 433 | await fs.ensureDir(loadedPluginOptions.fragmentsDir) 434 | 435 | // Fetch the meta data 436 | 437 | const reporter = gatsbyApi.reporter; 438 | reporter.info("Querying for Craft state."); 439 | const schema = await getSchema(); 440 | const queries = schema.getQueryType()?.getFields(); 441 | 442 | if (!queries) { 443 | reporter.info("Unable to fetch Craft schema."); 444 | return; 445 | } 446 | 447 | // Check if Craft endpoint has Gatsby plugin installed and enabled. 448 | if (!queries.sourceNodeInformation) { 449 | reporter.info("Gatsby Helper not found on target Craft site."); 450 | return; 451 | } 452 | 453 | if (!queries.craftVersion) { 454 | reporter.info("Gatsby Helper plugin must be at least version 1.1.0 or greater."); 455 | } 456 | 457 | 458 | const {data} = await execute({ 459 | operationName: 'craftState', 460 | query: `query craftState { 461 | configVersion 462 | lastUpdateTime 463 | primarySiteId 464 | gatsbyHelperVersion 465 | gqlTypePrefix 466 | craftVersion 467 | }`, 468 | variables: {}, 469 | additionalHeaders: { 470 | "X-Craft-Gql-Cache": "no-cache" 471 | } 472 | }); 473 | 474 | remoteConfigVersion = data.configVersion; 475 | lastUpdateTime = data.lastUpdateTime; 476 | craftGqlTypePrefix = data.gqlTypePrefix; 477 | gatsbyHelperVersion = data.gatsbyHelperVersion; 478 | craftPrimarySiteId = data.primarySiteId; 479 | craftVersion = data.craftVersion; 480 | 481 | // Avoid deprecation errors 482 | if (craftVersion >= '3.7.0') { 483 | craftElementIdField = 'canonicalId'; 484 | } 485 | 486 | reporter.info(`Craft v${craftVersion}, running Helper plugin v${gatsbyHelperVersion}`); 487 | // Make sure the fragments exist 488 | await ensureFragmentsExist(reporter) 489 | } 490 | 491 | exports.onPluginInit = async (gatsbyApi: NodePluginArgs, pluginOptions: SourcePluginOptions) => { 492 | await initializePlugin(pluginOptions, gatsbyApi); 493 | } 494 | 495 | exports.createSchemaCustomization = async (gatsbyApi: NodePluginArgs) => { 496 | const config = await getSourcingConfig(gatsbyApi) 497 | const { createTypes } = gatsbyApi.actions; 498 | 499 | let typeDefs = ''; 500 | 501 | for (let craftInterface of craftInterfaces) { 502 | let extraFields: {[key: string]: string} = {}; 503 | let extraFieldsAsString = ''; 504 | let redefineTypes = ''; 505 | 506 | const extractFieldType = (field: GraphQLField, onlyNullable: boolean): string|false => { 507 | const fieldName = field.name; 508 | const skippedTypes = ['id', 'parent', 'children', 'next', 'prev']; 509 | 510 | // If skipped type or begins with an underscore 511 | if (skippedTypes.includes(fieldName) || fieldName.charAt(0) === '_') { 512 | return false; 513 | } 514 | 515 | let fieldType = field.type.toString(); 516 | 517 | // If only nullable and is non-nullable 518 | if (onlyNullable && fieldType.slice(-1) == '!') { 519 | return false; 520 | } 521 | 522 | // If any arguments are required, can't have it. 523 | for (let fieldArgument of field.args) { 524 | if (fieldArgument.type.toString().slice(-1) == '!') { 525 | return false; 526 | } 527 | } 528 | 529 | // Convert Craft's DateTime to Gatsby's Date. 530 | fieldType = fieldType.replace(new RegExp(craftGqlTypePrefix + 'DateTime'), 'JSON'); 531 | 532 | if (fieldType.match(/(Int|Float|String|Boolean|ID|JSON)(\]|!|$)/)) { 533 | return fieldType; 534 | } 535 | 536 | return fieldType.replace(/^([^a-z]+)?([a-z_]+)([^a-z]+)?$/i, '$1' + loadedPluginOptions.typePrefix + '$2$3'); 537 | } 538 | 539 | // For all interfaces 540 | if (craftTypesByInterface[craftInterface]) { 541 | if (loadedPluginOptions.looseInterfaces) { 542 | // Collect all fields across all implementations of the interface if loose interfaces are enabled 543 | for (let gqlType of craftTypesByInterface[craftInterface]) { 544 | for (let field of Object.values(gqlType.getFields())) { 545 | let extractedType = extractFieldType(field, true); 546 | if (extractedType) { 547 | extraFields[field.name] = extractedType; 548 | } 549 | } 550 | } 551 | } else if (craftFieldsByInterface[craftInterface]) { 552 | // Otherwise just collect the interface fields 553 | for (let field of Object.values(craftFieldsByInterface[craftInterface])) { 554 | let extractedType = extractFieldType(field, false); 555 | if (extractedType) { 556 | extraFields[field.name] = extractedType; 557 | } 558 | } 559 | } 560 | 561 | // Create a string of all the fields we found. 562 | for (let [fieldName, fieldType] of Object.entries(extraFields)) { 563 | extraFieldsAsString += `${fieldName}: ${fieldType} 564 | `; 565 | } 566 | 567 | // If loose interfaces are enabled, redefine the types, too. 568 | if (loadedPluginOptions.looseInterfaces) { 569 | // And now redefine all the implementations to have all the fields. 570 | for (let gqlType of craftTypesByInterface[craftInterface]) { 571 | redefineTypes += `type ${loadedPluginOptions.typePrefix}${gqlType.name} { 572 | id: ID! 573 | ${extraFieldsAsString} 574 | }`; 575 | } 576 | } 577 | } 578 | 579 | typeDefs += ` 580 | interface ${loadedPluginOptions.typePrefix}${craftInterface} implements Node { 581 | id: ID! 582 | ${extraFieldsAsString} 583 | } 584 | 585 | ${redefineTypes} 586 | ` 587 | } 588 | 589 | createTypes(typeDefs); 590 | 591 | await createSchemaCustomization(config) 592 | } 593 | 594 | // @ts-ignore 595 | // Add `localFile` nodes to assets. 596 | exports.createResolvers = async ({ createResolvers, intermediateSchema, actions, cache, createNodeId, store, reporter }: CreateResolversArgs & {intermediateSchema: GraphQLSchema}) => { 597 | const { createNode } = actions; 598 | const ifaceName = `${loadedPluginOptions.typePrefix + craftGqlTypePrefix}AssetInterface`; 599 | const iface = intermediateSchema.getType(ifaceName) as GraphQLInterfaceType; 600 | 601 | if (iface) { 602 | const possibleTypes = intermediateSchema.getPossibleTypes(iface); 603 | const resolvers: {[key: string] : any} = {}; 604 | 605 | for (const assetType of possibleTypes) { 606 | resolvers[assetType.name] = { 607 | localFile: { 608 | type: `File`, 609 | async resolve(source: any) { 610 | if (source.url) { 611 | return await createRemoteFileNode({ 612 | url: encodeURI(source.url), 613 | store, 614 | cache, 615 | createNode, 616 | createNodeId, 617 | reporter 618 | }); 619 | } 620 | }, 621 | }, 622 | } 623 | } 624 | 625 | createResolvers(resolvers); 626 | } 627 | } 628 | 629 | // Source the actual Gatsby nodes 630 | exports.sourceNodes = async (gatsbyApi: NodePluginArgs) => { 631 | const {cache, reporter, webhookBody} = gatsbyApi 632 | const config = await getSourcingConfig(gatsbyApi) 633 | 634 | // If this is a webhook call 635 | if (webhookBody && typeof webhookBody == "object" && Object.keys(webhookBody).length) { 636 | reporter.info("Processing webhook."); 637 | const nodeEvent = (webhookBody: WebhookBody) => { 638 | const {operation, typeName, id, siteId} = webhookBody; 639 | let eventName = ''; 640 | 641 | switch (operation) { 642 | case 'delete': 643 | eventName = 'DELETE'; 644 | break; 645 | case 'update': 646 | eventName = 'UPDATE'; 647 | break; 648 | } 649 | 650 | previewToken = webhookBody.token ?? null; 651 | 652 | // Create the node event 653 | return { 654 | eventName, 655 | remoteTypeName: typeName, 656 | remoteId: {id, __typename: typeName, siteId}, 657 | } 658 | } 659 | 660 | // And source it 661 | await sourceNodeChanges(config, { 662 | nodeEvents: [nodeEvent(webhookBody as WebhookBody)], 663 | }) 664 | 665 | return; 666 | } 667 | 668 | const localConfigVersion = (await cache.get(`CRAFT_CONFIG_VERSION`)) || ''; 669 | const localContentUpdateTime = (await cache.get(`CRAFT_LAST_CONTENT_UPDATE`)) || ''; 670 | 671 | // If either project config changed or we don't have cached content, source it all 672 | if (remoteConfigVersion !== localConfigVersion || !localContentUpdateTime) { 673 | reporter.info("Cached content is unavailable or outdated, sourcing _all_ nodes."); 674 | await sourceAllNodes(config) 675 | } else { 676 | reporter.info(`Craft config version has not changed since last sourcing. Checking for content changes since "${localContentUpdateTime}".`); 677 | 678 | // otherwise, check for changed and deleted content. 679 | const {data} = await execute({ 680 | operationName: 'nodeChanges', 681 | query: `query nodeChanges { 682 | nodesUpdatedSince (since: "${localContentUpdateTime}" site: ${craftEnabledSites}) { nodeId nodeType siteId} 683 | nodesDeletedSince (since: "${localContentUpdateTime}") { nodeId nodeType siteId} 684 | }`, 685 | variables: {}, 686 | additionalHeaders: { 687 | "X-Craft-Gql-Cache": "no-cache" 688 | } 689 | }); 690 | 691 | const updatedNodes = data.nodesUpdatedSince as ModifiedNodeInfo[]; 692 | const deletedNodes = data.nodesDeletedSince as ModifiedNodeInfo[]; 693 | 694 | // Create the sourcing node events 695 | const nodeEvents = [ 696 | ...updatedNodes.map(entry => { 697 | return { 698 | eventName: 'UPDATE', 699 | remoteTypeName: entry.nodeType, 700 | remoteId: {__typename: entry.nodeType, id: entry.nodeId, siteId: entry.siteId} 701 | }; 702 | }), 703 | ...deletedNodes.map(entry => { 704 | return { 705 | eventName: 'DELETE', 706 | remoteTypeName: entry.nodeType, 707 | remoteId: {__typename: entry.nodeType, id: entry.nodeId, siteId: entry.siteId} 708 | }; 709 | }) 710 | ]; 711 | 712 | if (nodeEvents.length) { 713 | reporter.info("Sourcing changes for " + nodeEvents.length + " nodes."); 714 | } else { 715 | reporter.info("No content changes found."); 716 | } 717 | 718 | // And source, if needed 719 | await sourceNodeChanges(config, {nodeEvents}) 720 | } 721 | 722 | await cache.set(`CRAFT_CONFIG_VERSION`, remoteConfigVersion); 723 | await cache.set(`CRAFT_LAST_CONTENT_UPDATE`, lastUpdateTime); 724 | } 725 | 726 | async function getSourcingConfig(gatsbyApi: NodePluginArgs) { 727 | if (sourcingConfig) { 728 | return sourcingConfig 729 | } 730 | const schema = await getSchema() 731 | const gatsbyNodeTypes = await getGatsbyNodeTypes(gatsbyApi.reporter) 732 | 733 | const documents = await compileNodeQueries({ 734 | schema, 735 | gatsbyNodeTypes, 736 | customFragments: await collectFragments(), 737 | }) 738 | 739 | await writeCompiledQueries(documents) 740 | 741 | return (sourcingConfig = { 742 | gatsbyApi, 743 | schema, 744 | gatsbyNodeDefs: buildNodeDefinitions({gatsbyNodeTypes, documents}), 745 | gatsbyTypePrefix: loadedPluginOptions.typePrefix, 746 | execute: wrapQueryExecutorWithQueue(execute, {concurrency: loadedPluginOptions.concurrency}), 747 | verbose: loadedPluginOptions.verbose, 748 | }) 749 | } 750 | 751 | async function ensureFragmentsExist(reporter: Reporter) { 752 | reporter.info("Clearing previous fragments."); 753 | await fs.remove(internalFragmentDir, {recursive: true}); 754 | 755 | reporter.info("Writing default fragments."); 756 | await writeDefaultFragments(reporter); 757 | await addExtraFragments(reporter); 758 | } 759 | --------------------------------------------------------------------------------