├── index.js ├── .eslintrc ├── gatsby-browser.js ├── gatsby-ssr.js ├── lib ├── nodes.js ├── pieces.js └── pages.js ├── CHANGELOG.md ├── package.json ├── LICENSE ├── .gitignore ├── gatsby-node.js └── README.md /index.js: -------------------------------------------------------------------------------- 1 | // noop 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ], 3 | "rules": { 4 | "no-console": ["error", { "allow": ["warn", "error"] }] 5 | } 6 | } -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's Browser APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.com/docs/browser-apis/ 5 | */ 6 | // You can delete this file if you're not using it 7 | -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.com/docs/ssr-apis/ 5 | */ 6 | // You can delete this file if you're not using it 7 | -------------------------------------------------------------------------------- /lib/nodes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | generateNode: function (data, nodeType, utils) { 3 | data.aposId = data._id; 4 | data._id = undefined; 5 | utils.createNode({ 6 | ...data, 7 | id: utils.createNodeId(`${nodeType}-${data.aposId}`), 8 | parent: null, 9 | children: [], 10 | internal: { 11 | type: nodeType, 12 | content: JSON.stringify(data), 13 | contentDigest: utils.createContentDigest(data) 14 | } 15 | }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.1 4 | 5 | ### Fixes 6 | 7 | - Corrects the `apos-refresh` query parameter for page markup to `aposRefresh`. 8 | 9 | ## 1.0.0 10 | 11 | ### Fixes 12 | - Fixes a variable name typo (`superflous`). 13 | - Fixes links in the README. 14 | 15 | ### Changes 16 | - Replaces default Gatsby license. 17 | 18 | ## 1.0.0-beta 19 | 20 | The initial release of a Gatsby source plugin that connects to ApostropheCMS sites, providing: 21 | 22 | - Documentation for connecting a Gatsby site to an ApostropheCMS project 23 | - GraphQL queries to pull piece data from specifically identified piece types 24 | - GraphQL queries to use Apostrophe page data, including rendered HTML for the page 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-source-apostrophe", 3 | "version": "1.0.1", 4 | "description": "An ApostropheCMS source plugin for Gatsby", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "echo \"No build script setup\"" 9 | }, 10 | "keywords": [ 11 | "gatsby", 12 | "gatsby-plugin", 13 | "apostrophecms" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/apostrophecms/gatsby-source-apostrophe" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/apostrophecms/gatsby-source-apostrophe/issues" 21 | }, 22 | "author": "Apostrophe Technologies, Inc.", 23 | "license": "MIT", 24 | "dependencies": { 25 | "camelcase": "^6.2.0", 26 | "node-fetch": "^2.6.1" 27 | }, 28 | "devDependencies": { 29 | "eslint": "^7.16.0", 30 | "eslint-config-apostrophe": "^3.4.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variable files 55 | .env* 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Implement Gatsby's Node APIs in this file. 4 | * 5 | * See: https://www.gatsbyjs.com/docs/node-apis/ 6 | */ 7 | const { 8 | processPieceTypes 9 | } = require('./lib/pieces'); 10 | const { 11 | getPageIds, 12 | generatePageNodes 13 | } = require('./lib/pages'); 14 | 15 | exports.sourceNodes = async ({ 16 | actions, 17 | createContentDigest, 18 | createNodeId 19 | }, options) => { 20 | options = options || {}; 21 | const { createNode } = actions; 22 | 23 | if (!options.baseUrl) { 24 | throw Error('You are missing the `baseUrl` option for gatsby-source-apostrophe'); 25 | } 26 | if (!options.apiKey) { 27 | throw Error('You are missing the `apiKey` option for gatsby-source-apostrophe'); 28 | } 29 | 30 | const gatsbyUtils = { 31 | createNode, 32 | createNodeId, 33 | createContentDigest 34 | }; 35 | 36 | const routePathBase = options.routeBase || 'api/v1'; 37 | const apiRouteBase = `${options.baseUrl}/${routePathBase}`; 38 | 39 | if (Array.isArray(options.pieceTypes)) { 40 | await processPieceTypes(options.pieceTypes, { 41 | apiRouteBase, 42 | apiKey: options.apiKey 43 | }, gatsbyUtils); 44 | } 45 | 46 | const pageIds = await getPageIds(apiRouteBase, { 47 | apiKey: options.apiKey 48 | }); 49 | 50 | await generatePageNodes(pageIds, { 51 | baseUrl: options.baseUrl, 52 | apiRouteBase, 53 | apiKey: options.apiKey, 54 | renderPages: options.renderPages 55 | }, gatsbyUtils); 56 | }; 57 | -------------------------------------------------------------------------------- /lib/pieces.js: -------------------------------------------------------------------------------- 1 | const { generateNode } = require('./nodes'); 2 | const camelcase = require('camelcase').default; 3 | const fetch = require('node-fetch').default; 4 | 5 | module.exports = { 6 | processPieceTypes: async function (pieceTypes, { apiRouteBase, apiKey }, utils) { 7 | for (const type of pieceTypes) { 8 | const route = `${apiRouteBase}/${type}?apikey=${apiKey}&render-areas=true`; 9 | let data; 10 | 11 | try { 12 | const response = await fetch(route); 13 | data = await response.json(); 14 | } catch (error) { 15 | console.error('Error requesting Apostrophe pieces:', error); 16 | continue; 17 | } 18 | 19 | if (!data.results) { 20 | continue; 21 | } 22 | const totalPages = data.pages; 23 | 24 | const nodeTypeName = isNamespaced(type) 25 | ? convertNamespacing(type) 26 | : `Apos${camelcase(type, { pascalCase: true })}`; 27 | 28 | await generatePieceNodes(data.results, nodeTypeName, utils); 29 | 30 | if (totalPages <= 1) { 31 | continue; 32 | }; 33 | 34 | for (let i = 2; i <= totalPages; i++) { 35 | const pageResults = await getPiecePage(route, { 36 | apiKey: apiKey, 37 | page: i 38 | }); 39 | await generatePieceNodes(pageResults, nodeTypeName, utils); 40 | } 41 | 42 | } 43 | } 44 | }; 45 | 46 | async function generatePieceNodes (pieces, nodeType, utils) { 47 | // loop through data and create Gatsby nodes 48 | pieces.forEach(piece => { 49 | generateNode(piece, nodeType, utils); 50 | }); 51 | } 52 | 53 | function isNamespaced(name) { 54 | return name.match(/^@[A-Za-z0-9]*\/[A-Za-z0-9]*$/); 55 | } 56 | 57 | function convertNamespacing (name) { 58 | if (name.match(/^@apostrophecms\/[A-Za-z0-9]*$/)) { 59 | let typeName = name.split('/')[1]; 60 | typeName = camelcase(typeName, { pascalCase: true }); 61 | return `AposCore${typeName}`; 62 | } else { 63 | const cleanedName = name.slice(1).split('/').join('-'); 64 | return `Apos${camelcase(cleanedName, { pascalCase: true })}`; 65 | } 66 | } 67 | 68 | async function getPiecePage(route, opts) { 69 | const response = await fetch(`${route}?apikey=${opts.apiKey}&page=${opts.page}&render-areas=true`); 70 | const data = await response.json(); 71 | return data.results; 72 | } 73 | -------------------------------------------------------------------------------- /lib/pages.js: -------------------------------------------------------------------------------- 1 | const { generateNode } = require('./nodes'); 2 | const fetch = require('node-fetch').default; 3 | 4 | module.exports = { 5 | getPageIds: async function (routeBase, opts) { 6 | try { 7 | const response = await fetch(`${routeBase}/@apostrophecms/page?apikey=${opts.apiKey}&all=1&flat=1`); 8 | const data = await response.json(); 9 | const pages = data.results.filter(doc => doc.type !== '@apostrophecms/trash'); 10 | return pages.map(page => page._id); 11 | } catch (error) { 12 | console.error('Error requesting Apostrophe page IDs:', error); 13 | return []; 14 | } 15 | }, 16 | generatePageNodes: async function (ids, { 17 | baseUrl, apiRouteBase, apiKey, renderPages 18 | }, utils) { 19 | for (const id of ids) { 20 | let page; 21 | 22 | try { 23 | const response = await fetch(`${apiRouteBase}/@apostrophecms/page/${id}?apikey=${apiKey}&render-areas=true`); 24 | page = await response.json(); 25 | } catch (error) { 26 | console.error(`Error requesting Apostrophe page with ID ${id}`, error); 27 | return; 28 | } 29 | 30 | if (!page || page.message === 'notfound') { 31 | // This is most likely the trash "page" 32 | console.warn(`No page found for Apostrophe document ID ${id}.`); 33 | return; 34 | } 35 | 36 | if (renderPages !== false && page._url) { 37 | await addRenderedContent(page, baseUrl); 38 | } 39 | 40 | cleanupPage(page); 41 | 42 | generateNode(page, 'AposCorePage', utils); 43 | } 44 | } 45 | }; 46 | 47 | // TODO Add option to override `superfluous`. 48 | function cleanupPage(page) { 49 | // Remove document properties unneeded by Gatsby. 50 | superfluous.forEach(prop => { 51 | page[prop] = undefined; 52 | }); 53 | } 54 | 55 | async function addRenderedContent(page, apiBaseUrl) { 56 | try { 57 | const response = await fetch(`${page._url}?aposRefresh=1&headless=true`); 58 | page._rendered = await response.text(); 59 | } catch (error) { 60 | console.error('Error fetching rendered page HTML. If related to not having an absolute URL, check if `baseUrl` is set on the Apostrophe app.', error); 61 | page._rendered = ''; 62 | } 63 | } 64 | 65 | const superfluous = [ 66 | '_ancestors', 67 | '_edit', 68 | '_children', 69 | 'highSearchText', 70 | 'highSearchWords', 71 | 'historicUrls', 72 | 'lowSearchText', 73 | 'orphan', 74 | 'parked', 75 | 'parkedId', 76 | 'searchSummary', 77 | 'updatedBy' 78 | ]; 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gatsby-source-apostrophe 2 | 3 | Source plugin for accessing [ApostropheCMS 3.x content APIs](https://v3.docs.apostrophecms.org/reference/api/) in [Gatsby](https://www.gatsbyjs.com/docs/tutorial/) 4 | 5 | ## Install 6 | 7 | `npm install gatsby-source-apostrophe` 8 | 9 | ## How to use 10 | 11 | 1. Review [Gatsby documentation on using source plugins](https://www.gatsbyjs.com/docs/tutorial/part-five/). 🤓 12 | 2. [Add an API key to the Apostrophe app](https://v3.docs.apostrophecms.org/reference/api/authentication.html#api-keys) to include as the `apiKey` option. 13 | 3. Enter the Apos app root domain as the `baseUrl` option. For local development this will likely be `http://localhost:3000`. 14 | 4. Add an array of the piece types (custom content types) you want available to Gatsby in the `pieceTypes` array. 15 | 16 | ```javascript 17 | // In your gatsby-config.js 18 | module.exports = { 19 | plugins: [ 20 | { 21 | resolve: `gatsby-source-apostrophe`, 22 | options: { 23 | apiKey: 'my-apos-api-key', 24 | baseUrl: 'http://localhost:3000', 25 | pieceTypes: [ 26 | '@apostrophecms/file', 27 | 'my-piece-type' 28 | ] 29 | } 30 | } 31 | ] 32 | } 33 | ``` 34 | 35 | ## Options 36 | 37 | ### apiKey (required) 38 | 39 | An ApostropheCMS API key with permissions to make GET requests. [Read more about setting up API keys](https://v3.docs.apostrophecms.org/reference/api/authentication.html#api-keys) in the Apostrophe documentation. 40 | 41 | ### baseUrl (required) 42 | 43 | The base URL for API requests. Usually this will be the root domain of the Apostrophe website or application with no trailing slash. In local development it will be `http://localhost:3000` by default. 44 | 45 | ### pieceTypes 46 | 47 | An array of Apostrophe piece type names. These will the same as the key name entered in the `app.js` `modules` object in the Apostrophe app. You may include core piece types that are not included by default, e.g, `@apostrophecms/file`, `@apostrophecms/image-tag`. 48 | 49 | ### renderPages 50 | 51 | Defaults to `true`. Set this to `false` to avoid an additional request for each Apostrophe page in order to add the page's rendered HTML to the `AposCorePage` nodes. 52 | 53 | ## Notes 54 | 55 | ### Naming 56 | 57 | Apostrophe content node types in Gatsby's GraphQL API are all prefixed with `Apos`. So if you include your `article` piece type, it will appear in the GraphQL API as `AposArticle` nodes and the query collections will be `aposArticle` and `allAposArticle`. 58 | 59 | Apostrophe core piece types and pages are prefixed `AposCore`. For example, including the core `@apostrophecms/file` piece type will create `AposCoreFile` nodes. Other scoped module names will be converted to pascal-case, removing punctuation, and prefixed with `Apos`. For example, `@skynet/bad-robots` and `@skynet/badRobots` will be converted to `AposSkynetBadRobots`. 60 | 61 | ### Rendered content 62 | 63 | Apostrophe "pieces" are well suited to being delivered as structured, JSON-like data. However, the power of Apostrophe really shines when editors build custom series of content widgets in "areas." Because areas can contain many types of widgets, and thus are not consistent in data structure, it is usually more useful to retrieve those from the APIs as rendered HTML. 64 | 65 | Area fields on piece types will be available in the GraphQL queries with a `_rendered` property containing a string of rendered HTML. All other field types are delivered directly as their normal data types, e.g., strings, number, arrays, etc. 66 | 67 | #### Rendered pages 68 | 69 | Apostrophe pages usually consist primarily of these content "areas," populated with any number of different widget types. Unless you add `renderPages: false` to this source plugin's options, pages will appear with a `_rendered` property in Gatsby GraphQL queries. This property's value is a string of HTML, rendered using the relevant page template in the Apostrophe app. See [the page API documentation](https://v3.docs.apostrophecms.org/reference/api/pages.html#delete-api-v1-apostrophecms-page-id) for more specifics on what is rendered. 70 | 71 | That HTML can be used in your Gatsby site to [programmatically create pages](https://www.gatsbyjs.com/docs/tutorial/part-seven/) using the right layout component and slug structure for your site. 72 | 73 | If there are parts of the Apostrophe page templates that you do *not* want to include in the rendered response, you can wrap those in the following Nunjucks conditional (these requests include a `?headless=true` parameter): 74 | 75 | ```django 76 | 77 | {% if not data.query.headless == 'true' %} 78 | 79 | {% endif %} 80 | ``` 81 | 82 | ## Planned features 83 | 84 | - A video player JS to support the Apostrophe core video widget in areas 85 | - Option for Apostrophe pages to automatically generate Gatsby pages 86 | - An option to make all piece types available in Gatsby GraphQL queries without naming them 87 | 88 | ## License 89 | gatsby-source-apostrophe is released under the [MIT License](https://github.com/apostrophecms/gatsby-source-apostrophe/blob/main/LICENSE). 90 | --------------------------------------------------------------------------------