├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── README.md ├── gridsome.client.js ├── index.js ├── package.json ├── tests └── units │ ├── create-schema.test.js │ ├── filter-additional-types.test.js │ ├── get-client-options.test.js │ ├── get-languages.test.js │ ├── get-path.test.js │ ├── load-data.test.js │ └── transform-story.test.js ├── utils ├── constants.js ├── create-directory.js ├── create-schema.js ├── filter-additional-types.js ├── get-client-options.js ├── get-languages.js ├── get-path.js ├── index.js ├── load-data.js ├── process-image.js └── transform-story.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es6: true, 5 | node: true, 6 | 'jest/globals': true 7 | }, 8 | extends: [ 9 | 'standard' 10 | ], 11 | globals: { 12 | Atomics: 'readonly', 13 | SharedArrayBuffer: 'readonly' 14 | }, 15 | parserOptions: { 16 | ecmaVersion: 2018 17 | }, 18 | plugins: [ 19 | 'jest' 20 | ], 21 | rules: { 22 | } 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # gatsby files 85 | .cache/ 86 | public 87 | 88 | # vuepress build output 89 | .vuepress/dist 90 | 91 | # Serverless directories 92 | .serverless/ 93 | 94 | # FuseBox cache 95 | .fusebox/ 96 | 97 | # DynamoDB Local files 98 | .dynamodb/ 99 | 100 | # TernJS port file 101 | .tern-port -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 12 3 | after_success: 4 | - npx semantic-release -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gridsome Source Storyblok 2 | 3 | The official [Storyblok](https://www.storyblok.com/) integration with [Gridsome](https://gridsome.org/). 4 | 5 | To see it in action take a look at the [Storyblok Gridsome Boilerplate](https://github.com/storyblok/storyblok-gridsome-boilerplate). 6 | 7 | ![Version](https://flat.badgen.net/npm/v/gridsome-source-storyblok?icon=npm) 8 | ![Downloads](https://flat.badgen.net/npm/dm/gridsome-source-storyblok?icon=npm) 9 | ![Stars](https://flat.badgen.net/github/stars/storyblok/gridsome-source-storyblok?icon=github) 10 | ![Status](https://flat.badgen.net/github/status/storyblok/gridsome-source-storyblok?icon=github) 11 | 12 | ## Install 13 | 14 | ```sh 15 | yarn add gridsome-source-storyblok # or npm install gridsome-source-storyblok 16 | ``` 17 | 18 | ## Usage 19 | 20 | 1. In `gridsome.config.js`, declare the use of the plugin and define the options: 21 | 22 | ```js 23 | // in gridsome.config.js 24 | { 25 | siteName: 'Gridsome', 26 | plugins: [ 27 | { 28 | use: 'gridsome-source-storyblok', 29 | options: { 30 | client: { 31 | accessToken: '' 32 | }, 33 | types: { 34 | story: { 35 | typeName: 'StoryblokEntry' 36 | } 37 | } 38 | } 39 | } 40 | ] 41 | } 42 | ``` 43 | 44 | 2. In the `src/templates` folder create a `.vue` file with the same name you defined in the `typeName` option (default is `StoryblokEntry`). After that set a `` tag to load the data from GraphQL. For example: 45 | 46 | ```html 47 | 48 | query StoryblokEntry ($id: ID) { 49 | storyblokEntry (id: $id) { 50 | id 51 | slug 52 | content 53 | } 54 | } 55 | 56 | ``` 57 | 58 | 3. Edit the file `gridsome.server.js` to use a GraphQL query to generate the pages using Storyblok's full_slug attribute 59 | 60 | ```js 61 | module.exports = function (api) { 62 | api.createPages(async ({ graphql, createPage }) => { 63 | const { data } = await graphql(`{ 64 | allStoryblokEntry { 65 | edges { 66 | node { 67 | id 68 | full_slug 69 | } 70 | } 71 | } 72 | }`) 73 | 74 | data.allStoryblokEntry.edges.forEach(({ node }) => { 75 | createPage({ 76 | path: `/${node.full_slug}`, 77 | component: './src/templates/StoryblokEntry.vue', 78 | context: { 79 | id: node.id 80 | } 81 | }) 82 | }) 83 | }) 84 | } 85 | ``` 86 | 87 | ## The options object in details 88 | 89 | When you declare the use of the Storyblok plugin you can pass following options: 90 | 91 | ```js 92 | { 93 | use: 'gridsome-source-storyblok', 94 | options: { 95 | client: { 96 | // The Storyblok JS Client options here (https://github.com/storyblok/storyblok-js-client) 97 | accessToken: '' // required! 98 | }, 99 | version: 'draft', // Optional. Can be draft or published (default draft) 100 | // Optional: Config story and tag types names and request calls 101 | types: { 102 | story: { 103 | name: 'StoryblokEntry', // The name of Story template and type (default StoryblokEntry) 104 | params: {} // Additional query parameters 105 | }, 106 | tag: { 107 | name: 'StoryblokTag', // The name of Tag template and type (default StoryblokTag) 108 | params: {} // Additional query parameters 109 | } 110 | }, 111 | downloadImages: true, // Optional. default false, 112 | imageDirectory: 'storyblok_images', // Optional. Folder to put the downloaded images 113 | // Optional: Get additional types like datasources, links or datasource_entries 114 | additionalTypes: [ 115 | { 116 | type: 'datasources', // required 117 | name: 'StoryblokDatasource' // required 118 | }, 119 | { 120 | type: 'datasource_entries', 121 | name: 'StoryblokDatasourceEntry', 122 | params: { ...additionalQueryParams } // optional 123 | }, 124 | { 125 | type: 'links', 126 | name: 'StoryblokLink' 127 | } 128 | ] 129 | } 130 | } 131 | ``` 132 | 133 | ## Rendering of the Rich Text field 134 | 135 | This plugin comes with a built in renderer to get html output from Storyblok's Rich Text field. Create and register a `Richtext.vue` component with the code below to use the renderer in your components like this: ``. 136 | 137 | ~~~js 138 | 143 | 144 | 154 | ~~~ 155 | 156 | ## Downloading images 157 | 158 | When `downloadImages` option is marked as true, this plugin will be searching in each story for a image and download it to `src/` folder. In your components, you can use the [g-image](https://gridsome.org/docs/images/) tag. An important thing is that image property in story will be a object with some fields, not a string. Bellow, we show you an example of this: 159 | 160 | ```html 161 | 166 | 167 | 185 | 186 | 191 | ``` 192 | 193 | ## Working with Tags 194 | 195 | By default, this plugin will get all tags and create a reference to stories entries (as described in `create-schema` function), so it's possible to list stories from tag, for example. 196 | 197 | You can change the name of template file and types by setting the `options.types.tag.name` option in `gridsome.config.js` (`StoryblokTag` is default). 198 | 199 | ### Example 200 | 201 | Create a `StoryblokTag.vue` file in `src/templates` folder with the following code: 202 | 203 | ```vue 204 | 216 | 217 | 218 | query ($id: ID!) { 219 | storyblokTag(id: $id) { 220 | name 221 | belongsTo { 222 | edges { 223 | node { 224 | ... on StoryblokEntry { 225 | id 226 | full_slug 227 | name 228 | } 229 | } 230 | } 231 | } 232 | } 233 | } 234 | 235 | ``` 236 | 237 | In your `gridsome.server.js` file, it will be necessary to create a pages for each tag as the following: 238 | 239 | ```js 240 | module.exports = function (api) { 241 | api.createPages(async ({ graphql, createPage }) => { 242 | // previous code (create pages to stories) 243 | 244 | const { data: tagData } = await graphql(`{ 245 | allStoryblokTag { 246 | edges { 247 | node { 248 | id 249 | name 250 | } 251 | } 252 | } 253 | }`) 254 | 255 | tagData.allStoryblokTag.edges.forEach(({ node }) => { 256 | createPage({ 257 | path: `/tag/${node.name}`, 258 | component: './src/templates/StoryblokTag.vue', 259 | context: { 260 | id: node.id 261 | } 262 | }) 263 | }) 264 | }) 265 | }) 266 | ``` 267 | 268 | That's all! In your browser you can view a list of stories by the `foo` tag in `http://localhost:8080/tag/foo`. 269 | 270 | ## Load data to different collections 271 | 272 | To load data to multiple collections, you need to declare the configuration multiple times in `gridsome.config.js`. Like this: 273 | 274 | ```js 275 | { 276 | siteName: 'Gridsome', 277 | plugins: [ 278 | // default collection 279 | { 280 | use: 'gridsome-source-storyblok', 281 | options: { 282 | client: { 283 | accessToken: '' 284 | } 285 | } 286 | }, 287 | 288 | // specific collection (blogging for example) 289 | { 290 | use: 'gridsome-source-storyblok', 291 | options: { 292 | client: { 293 | accessToken: '' 294 | }, 295 | types: { 296 | story: { 297 | name: 'StoryblokBlogEntry', 298 | params: { 299 | starts_with: 'blog/' 300 | } 301 | }, 302 | tag: { 303 | typeName: 'StoryblokBlogTag' 304 | } 305 | } 306 | } 307 | } 308 | ] 309 | } 310 | ``` 311 | 312 | And, in your `gridsome.server.js`, you can generate your pages for each collection, attending to the name given to each collection. 313 | 314 | ## Get Space informations 315 | 316 | It is possible to get the space informations using the GraphQL Data Layer. The space information will be storage in [Gridsome's global metadata](https://gridsome.org/docs/metadata/#global-metadata), so, it will be avaialable for your entire project. 317 | 318 | To get the space informations, you can set this following query in your `` in your vue component: 319 | 320 | ``` 321 | query { 322 | metadata { 323 | storyblokSpace { 324 | id 325 | name 326 | version 327 | language_codes 328 | } 329 | } 330 | } 331 | ``` 332 | 333 | ## Contribution 334 | 335 | Fork me on [Github](https://github.com/storyblok/gridsome-source-storyblok). 336 | 337 | This project use [semantic-release](https://semantic-release.gitbook.io/semantic-release/) for generate new versions by using commit messages and we use the Angular Convention to naming the commits. Check [this question](https://semantic-release.gitbook.io/semantic-release/support/faq#how-can-i-change-the-type-of-commits-that-trigger-a-release) about it in semantic-release FAQ. 338 | -------------------------------------------------------------------------------- /gridsome.client.js: -------------------------------------------------------------------------------- 1 | import StoryblokVue from 'storyblok-vue' 2 | import StoryblokClient from 'storyblok-js-client' 3 | 4 | export default function (Vue, options, context) { 5 | const Client = { 6 | install () { 7 | if (!Vue.prototype.$storyapi) { 8 | Vue.prototype.$storyapi = new StoryblokClient(options.client) 9 | } 10 | } 11 | } 12 | 13 | Vue.use(StoryblokVue) 14 | Vue.use(Client) 15 | } 16 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const StoryblokClient = require('storyblok-js-client') 2 | const { 3 | getLanguages, 4 | getSpace, 5 | createSchema, 6 | processData, 7 | createDirectory, 8 | processTagData, 9 | processStoriesData, 10 | filterAdditionalTypes 11 | } = require('./utils') 12 | 13 | const { 14 | IMAGE_DIRECTORY, 15 | SOURCE_ROOT, 16 | SCHEMA_NAMES 17 | } = require('./utils/constants') 18 | 19 | /** 20 | * @method StoryblokPlugin 21 | * @param {Object} api https://gridsome.org/docs/server-api/ 22 | * @param {Object} options 23 | * @param {Object} options.client - client configuration 24 | * @param {String} options.version - can be draft or published (default draft) 25 | * @param {Object} options.types - an options object to setup story and tags 26 | * @param {Array} options.additionalTypes - an array of objects to setup additional types, like datasources, links among others 27 | */ 28 | const StoryblokPlugin = (api, options) => { 29 | if (!options.client) { 30 | console.error('[gridsome-source-storyblok] The client option is required') 31 | return 32 | } 33 | 34 | if (!options.client.accessToken) { 35 | console.error('[gridsome-source-storyblok] The accessToken option is required') 36 | return 37 | } 38 | 39 | const Storyblok = new StoryblokClient(options.client) 40 | const typesConfig = options.types || {} 41 | const tagType = typesConfig.tag || {} 42 | const storyType = typesConfig.story || {} 43 | const typeName = storyType.name || SCHEMA_NAMES.STORY 44 | const tagTypeName = tagType.name || SCHEMA_NAMES.TAG 45 | 46 | api.loadSource(async store => { 47 | const space = await getSpace(Storyblok) 48 | const languages = getLanguages(space.language_codes) 49 | 50 | const storyblokOptions = { 51 | version: options.version || 'draft' 52 | } 53 | 54 | const schemaNames = { 55 | typeName, 56 | tagTypeName 57 | } 58 | 59 | createSchema(store, schemaNames) 60 | 61 | /** 62 | * CREATING THE SPACE IN METADATA 63 | */ 64 | store.addMetadata('storyblokSpace', space) 65 | 66 | /** 67 | * SPECIFIC FOR STORIES ENTRYPOINT 68 | */ 69 | const downloadImages = options.downloadImages || false 70 | const imageDirectory = options.imageDirectory || IMAGE_DIRECTORY 71 | 72 | if (downloadImages) { 73 | createDirectory(`${SOURCE_ROOT}${imageDirectory}`) 74 | } 75 | 76 | for (const language of languages) { 77 | const entity = { 78 | type: 'stories', 79 | name: typeName 80 | } 81 | 82 | const optionsData = { 83 | per_page: 25, 84 | ...storyType.params || {}, 85 | ...storyblokOptions 86 | } 87 | 88 | const collection = store.addCollection({ 89 | typeName: entity.name 90 | }) 91 | 92 | // adding a reference in tag_list field to Tags type 93 | collection.addReference('tag_list', tagTypeName) 94 | 95 | const pluginOptions = { 96 | downloadImages, 97 | imageDirectory 98 | } 99 | 100 | await processStoriesData( 101 | collection, 102 | Storyblok, 103 | entity, 104 | optionsData, 105 | language, 106 | pluginOptions 107 | ) 108 | } 109 | 110 | /** 111 | * SPECIFIC FOR TAGS ENTRYPOINT 112 | */ 113 | const entity = { 114 | type: 'tags', 115 | name: tagTypeName 116 | } 117 | 118 | const optionsData = { 119 | per_page: 25, 120 | ...tagType.params || {}, 121 | ...storyblokOptions 122 | } 123 | 124 | const collection = store.addCollection({ 125 | typeName: entity.name 126 | }) 127 | 128 | await processTagData( 129 | collection, 130 | Storyblok, 131 | entity, 132 | optionsData 133 | ) 134 | 135 | /** 136 | * TO ADDITIONAL TYPES 137 | */ 138 | const additionalTypes = filterAdditionalTypes(options.additionalTypes || []) 139 | for (const entityType of additionalTypes) { 140 | const params = entityType.params || {} 141 | 142 | const optionsData = { 143 | ...params, 144 | ...storyblokOptions 145 | } 146 | 147 | const collection = store.addCollection({ 148 | typeName: entityType.name 149 | }) 150 | 151 | await processData( 152 | collection, 153 | Storyblok, 154 | entityType, 155 | optionsData 156 | ) 157 | } 158 | }) 159 | } 160 | 161 | module.exports = StoryblokPlugin 162 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gridsome-source-storyblok", 3 | "description": "Storyblok source for Gridsome with live preview editor", 4 | "version": "0.0.0-development", 5 | "main": "index.js", 6 | "repository": "https://github.com/storyblok/gridsome-source-storyblok.git", 7 | "author": "Emanuel Souza , Alexander Feiglstorfer =8" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/storyblok/gridsome-source-storyblok/issues" 20 | }, 21 | "dependencies": { 22 | "lodash": "^4.17.20", 23 | "storyblok-js-client": "^4.0.6", 24 | "storyblok-vue": "^1.0.5" 25 | }, 26 | "keywords": [ 27 | "gridsome", 28 | "gridsome-plugin", 29 | "storyblok" 30 | ], 31 | "devDependencies": { 32 | "axios": "^0.21.1", 33 | "eslint": "^7.10.0", 34 | "eslint-config-standard": "^14.1.1", 35 | "eslint-plugin-import": "^2.22.1", 36 | "eslint-plugin-jest": "^24.0.2", 37 | "eslint-plugin-node": "^11.1.0", 38 | "eslint-plugin-promise": "^4.2.1", 39 | "eslint-plugin-standard": "^4.0.1", 40 | "jest": "^26.4.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/units/create-schema.test.js: -------------------------------------------------------------------------------- 1 | const { createSchema } = require('../../utils') 2 | 3 | /** 4 | * Get the order argument and remove spaces because use literal templates 5 | * 6 | * @method getCallResult 7 | * @param {Function} fn function mocked 8 | * @param {Number} order order 9 | * @return {String} 10 | */ 11 | const getCallResult = (fn, order) => { 12 | return fn.mock.calls[order][0].replace(/\s/g, '') 13 | } 14 | 15 | describe('createSchema function', () => { 16 | test('call createSchema should be call addSchemaTypes function 4 times', () => { 17 | const store = {} 18 | store.addSchemaTypes = jest.fn(str => str) 19 | createSchema(store) 20 | 21 | expect(store.addSchemaTypes.mock.calls.length).toBe(4) 22 | }) 23 | 24 | test('the second function call receive a correct implementation of StoryblokTag node', () => { 25 | const result = ` 26 | type StoryblokTag implements Node { 27 | id: ID! 28 | name: String! 29 | taggings_count: Int! 30 | }` 31 | 32 | const store = {} 33 | store.addSchemaTypes = jest.fn(str => str) 34 | createSchema(store, {}) 35 | 36 | const data = getCallResult(store.addSchemaTypes, 1) 37 | 38 | expect(data).toEqual(result.replace(/\s/g, '')) 39 | }) 40 | 41 | test('the third function call receive a correct implementation of StoryblokEntry node', () => { 42 | const result = ` 43 | type StoryblokEntry implements Node { 44 | content: JSONObject 45 | name: String! 46 | created_at: Date 47 | published_at: Date 48 | id: ID! 49 | slug: String! 50 | full_slug: String! 51 | uuid: String! 52 | real_path: String 53 | lang: String 54 | position: Int 55 | is_startpage: Boolean 56 | parent_id: Int 57 | group_id: String 58 | first_published_at: Date 59 | release_id: Int 60 | tag_list: [StoryblokTag!]! 61 | meta_data: JSONObject 62 | sort_by_date: Date 63 | alternates: [AlternateStory!]! 64 | }` 65 | 66 | const store = {} 67 | store.addSchemaTypes = jest.fn(str => str) 68 | createSchema(store, {}) 69 | 70 | const data = getCallResult(store.addSchemaTypes, 2) 71 | 72 | expect(data).toEqual(result.replace(/\s/g, '')) 73 | }) 74 | 75 | test('when call createSchema with another parameters, the schema should be created with them', () => { 76 | const tagResult = ` 77 | type Tag implements Node { 78 | id: ID! 79 | name: String! 80 | taggings_count: Int! 81 | }` 82 | 83 | const storyResult = ` 84 | type Story implements Node { 85 | content: JSONObject 86 | name: String! 87 | created_at: Date 88 | published_at: Date 89 | id: ID! 90 | slug: String! 91 | full_slug: String! 92 | uuid: String! 93 | real_path: String 94 | lang: String 95 | position: Int 96 | is_startpage: Boolean 97 | parent_id: Int 98 | group_id: String 99 | first_published_at: Date 100 | release_id: Int 101 | tag_list: [Tag!]! 102 | meta_data: JSONObject 103 | sort_by_date: Date 104 | alternates: [AlternateStory!]! 105 | }` 106 | 107 | const store = {} 108 | store.addSchemaTypes = jest.fn(str => str) 109 | const config = { 110 | typeName: 'Story', 111 | tagTypeName: 'Tag' 112 | } 113 | 114 | createSchema(store, config) 115 | // get the second and the third arguments and remove spaces because use literal templates 116 | const tagData = getCallResult(store.addSchemaTypes, 1) 117 | const storyData = getCallResult(store.addSchemaTypes, 2) 118 | 119 | expect(tagData).toEqual(tagResult.replace(/\s/g, '')) 120 | expect(storyData).toEqual(storyResult.replace(/\s/g, '')) 121 | }) 122 | 123 | test('the fourth function call receive a correct implementation of Metadata', () => { 124 | const result = ` 125 | type StoryblokSpaceType { 126 | id: ID! 127 | name: String 128 | domain: String 129 | version: Int 130 | language_codes: [String] 131 | } 132 | 133 | type Metadata @infer { 134 | storyblokSpace: StoryblokSpaceType 135 | } 136 | ` 137 | 138 | const store = {} 139 | store.addSchemaTypes = jest.fn(str => str) 140 | createSchema(store, {}) 141 | 142 | const data = getCallResult(store.addSchemaTypes, 3) 143 | 144 | expect(data).toEqual(result.replace(/\s/g, '')) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /tests/units/filter-additional-types.test.js: -------------------------------------------------------------------------------- 1 | const { filterAdditionalTypes } = require('../../utils') 2 | 3 | describe('filterAdditionalTypes function', () => { 4 | test('without arguments should be return a empty array ', () => { 5 | expect(filterAdditionalTypes()).toEqual([]) 6 | }) 7 | 8 | test('with a correct list of additionalTypes, should be return all', () => { 9 | const options = [ 10 | { 11 | type: 'links', 12 | name: 'StoryblokLink' 13 | }, 14 | { 15 | type: 'datasources', 16 | name: 'StoryblokDatasources' 17 | }, 18 | { 19 | type: 'datasource_entries', 20 | name: 'StoryblokDatasourcesEntries' 21 | } 22 | ] 23 | 24 | expect(filterAdditionalTypes(options)).toEqual(options) 25 | }) 26 | 27 | test('with a list including tags, should be return all objects without tags', () => { 28 | const options = [ 29 | { 30 | type: 'datasources', 31 | name: 'StoryblokDatasources' 32 | }, 33 | { 34 | type: 'datasource_entries', 35 | name: 'StoryblokDatasourcesEntries' 36 | }, 37 | { 38 | type: 'tags', 39 | name: 'StoryblokTag' 40 | } 41 | ] 42 | 43 | const result = [ 44 | { 45 | type: 'datasources', 46 | name: 'StoryblokDatasources' 47 | }, 48 | { 49 | type: 'datasource_entries', 50 | name: 'StoryblokDatasourcesEntries' 51 | } 52 | ] 53 | 54 | expect(filterAdditionalTypes(options)).toEqual(result) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /tests/units/get-client-options.test.js: -------------------------------------------------------------------------------- 1 | const getClientOptions = require('../../utils/get-client-options') 2 | 3 | describe('getClientOptions function', () => { 4 | test('getClientOptions() should be {}', () => { 5 | expect(getClientOptions()).toEqual({}) 6 | }) 7 | 8 | test("getClientOptions('pt/') should be { starts_with: 'pt/*' }", () => { 9 | expect(getClientOptions('pt/')).toEqual({ starts_with: 'pt/*' }) 10 | }) 11 | 12 | test("getClientOptions('pt/', { page: 1, version: 'draft' }) should be { starts_with: 'pt', page: 1, version: 'draft' }", () => { 13 | const options = { 14 | page: 1, 15 | version: 'draft' 16 | } 17 | const result = { 18 | starts_with: 'pt/*', 19 | page: 1, 20 | version: 'draft' 21 | } 22 | expect(getClientOptions('pt/', options)).toEqual(result) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /tests/units/get-languages.test.js: -------------------------------------------------------------------------------- 1 | const { getLanguages } = require('../../utils') 2 | 3 | describe('getLanguages function', () => { 4 | test("getLanguages() should be ['']", () => { 5 | expect(getLanguages()).toEqual(['']) 6 | }) 7 | 8 | test("getLanguages([]) should be ['']", () => { 9 | expect(getLanguages([])).toEqual(['']) 10 | }) 11 | 12 | test("getLanguages(['pt']) should be ['pt/', '']", () => { 13 | expect(getLanguages(['pt'])).toEqual(['pt/', '']) 14 | }) 15 | 16 | test("getLanguages(['pt', 'de']) should be ['pt/', 'de/', '']", () => { 17 | expect(getLanguages(['pt', 'de'])).toEqual(['pt/', 'de/', '']) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /tests/units/get-path.test.js: -------------------------------------------------------------------------------- 1 | const { getPath } = require('../../utils') 2 | 3 | describe('getPath function', () => { 4 | test("getPath() should be 'cdn'", () => { 5 | expect(getPath()).toBe('cdn/') 6 | }) 7 | 8 | test("getPath('') should be 'cdn'", () => { 9 | expect(getPath('')).toBe('cdn/') 10 | }) 11 | 12 | test("getPath('stories') should be 'cdn/stories'", () => { 13 | expect(getPath('stories')).toBe('cdn/stories') 14 | }) 15 | 16 | test("getPath('links') should be 'cdn/links'", () => { 17 | expect(getPath('links')).toBe('cdn/links') 18 | }) 19 | 20 | test("getPath('tags') should be 'cdn/tags'", () => { 21 | expect(getPath('tags')).toBe('cdn/tags') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/units/load-data.test.js: -------------------------------------------------------------------------------- 1 | const { loadData } = require('../../utils') 2 | 3 | describe('loadData function', () => { 4 | test('loadData should call the get function only one time', () => { 5 | const client = { 6 | get: jest.fn(() => ({})) 7 | } 8 | const entity = 'stories' 9 | const page = 1 10 | const options = { 11 | version: 'draft' 12 | } 13 | loadData(client, entity, page, options) 14 | 15 | expect(client.get.mock.calls.length).toBe(1) 16 | }) 17 | 18 | test('loadData with correct path and options ', () => { 19 | const client = { 20 | get: jest.fn(() => ({})) 21 | } 22 | const entity = 'stories' 23 | const page = 2 24 | const options = { 25 | version: 'published' 26 | } 27 | loadData(client, entity, page, options) 28 | 29 | const path = client.get.mock.calls[0][0] 30 | const _options = client.get.mock.calls[0][1] 31 | 32 | expect(path).toBe('cdn/stories') 33 | expect(_options).toEqual({ 34 | version: 'published', 35 | page: 2 36 | }) 37 | }) 38 | 39 | test('loadData with correct path and options when pass a language', () => { 40 | const client = { 41 | get: jest.fn(() => ({})) 42 | } 43 | const entity = 'tags' 44 | const page = 2 45 | const options = { 46 | version: 'published' 47 | } 48 | loadData(client, entity, page, options, 'pt/') 49 | 50 | const path = client.get.mock.calls[0][0] 51 | const _options = client.get.mock.calls[0][1] 52 | 53 | expect(path).toBe('cdn/tags') 54 | expect(_options).toEqual({ 55 | version: 'published', 56 | page: 2, 57 | starts_with: 'pt/*' 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /tests/units/transform-story.test.js: -------------------------------------------------------------------------------- 1 | const transformStory = require('../../utils/transform-story') 2 | 3 | describe('transformStory function', () => { 4 | test('call transformStory should be return a object with id transformed', () => { 5 | const data = { 6 | name: 'Home', 7 | lang: 'default', 8 | id: 123456, 9 | path: null 10 | } 11 | 12 | const result = { 13 | name: 'Home', 14 | lang: 'default', 15 | id: 'story-123456-default', 16 | real_path: null 17 | } 18 | expect(transformStory(data)).toEqual(result) 19 | }) 20 | 21 | test('call transformStory with more data should be return a object with id transformed does not affecting the rest of object', () => { 22 | const data = { 23 | slug: 'home', 24 | full_slug: 'pt/home', 25 | name: 'Home', 26 | lang: 'pt', 27 | id: 123456, 28 | path: null 29 | } 30 | 31 | const result = { 32 | slug: 'home', 33 | full_slug: 'pt/home', 34 | name: 'Home', 35 | lang: 'pt', 36 | id: 'story-123456-pt', 37 | real_path: null 38 | } 39 | expect(transformStory(data)).toEqual(result) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /utils/constants.js: -------------------------------------------------------------------------------- 1 | const PLUGIN_ROOT = process.cwd() 2 | 3 | const IMAGE_DIRECTORY = 'storyblok_images' 4 | 5 | const SOURCE_ROOT = 'src/' 6 | 7 | const SCHEMA_NAMES = { 8 | TAG: 'StoryblokTag', 9 | STORY: 'StoryblokEntry' 10 | } 11 | 12 | const ALLOWED_ADDITIONAL_TYPES = ['datasources', 'datasource_entries', 'links'] 13 | 14 | module.exports = { 15 | PLUGIN_ROOT, 16 | IMAGE_DIRECTORY, 17 | SOURCE_ROOT, 18 | SCHEMA_NAMES, 19 | ALLOWED_ADDITIONAL_TYPES 20 | } 21 | -------------------------------------------------------------------------------- /utils/create-directory.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const { PLUGIN_ROOT } = require('./constants') 5 | 6 | /** 7 | * @method createDirectory 8 | * @param {String} dir 9 | * @return String 10 | */ 11 | const createDirectory = dir => { 12 | const pwd = path.join(PLUGIN_ROOT, dir) 13 | 14 | if (!fs.existsSync(pwd)) { 15 | fs.mkdirSync(pwd) 16 | } 17 | 18 | return pwd 19 | } 20 | 21 | module.exports = createDirectory 22 | -------------------------------------------------------------------------------- /utils/create-schema.js: -------------------------------------------------------------------------------- 1 | const { SCHEMA_NAMES } = require('./constants') 2 | 3 | /** 4 | * @method createSchema 5 | * @param {Object} store Gridsome Data Store API 6 | * @param {String} typeName typeName from plugin option 7 | */ 8 | const createSchema = (store, config = {}) => { 9 | const typeName = config.typeName || SCHEMA_NAMES.STORY 10 | const tagTypeName = config.tagTypeName || SCHEMA_NAMES.TAG 11 | 12 | store.addSchemaTypes(` 13 | type AlternateStory { 14 | id: ID! 15 | name: String! 16 | slug: String! 17 | published: Boolean 18 | full_slug: String! 19 | is_folder: Boolean 20 | parent_id: Int 21 | } 22 | `) 23 | 24 | store.addSchemaTypes(` 25 | type ${tagTypeName} implements Node { 26 | id: ID! 27 | name: String! 28 | taggings_count: Int! 29 | } 30 | `) 31 | 32 | store.addSchemaTypes(` 33 | type ${typeName} implements Node { 34 | content: JSONObject 35 | name: String! 36 | created_at: Date 37 | published_at: Date 38 | id: ID! 39 | slug: String! 40 | full_slug: String! 41 | uuid: String! 42 | real_path: String 43 | lang: String 44 | position: Int 45 | is_startpage: Boolean 46 | parent_id: Int 47 | group_id: String 48 | first_published_at: Date 49 | release_id: Int 50 | tag_list: [${tagTypeName}!]! 51 | meta_data: JSONObject 52 | sort_by_date: Date 53 | alternates: [AlternateStory!]! 54 | } 55 | `) 56 | 57 | store.addSchemaTypes(` 58 | type StoryblokSpaceType { 59 | id: ID! 60 | name: String 61 | domain: String 62 | version: Int 63 | language_codes: [String] 64 | } 65 | 66 | type Metadata @infer { 67 | storyblokSpace: StoryblokSpaceType 68 | } 69 | `) 70 | } 71 | 72 | module.exports = createSchema 73 | -------------------------------------------------------------------------------- /utils/filter-additional-types.js: -------------------------------------------------------------------------------- 1 | const { ALLOWED_ADDITIONAL_TYPES } = require('./constants') 2 | 3 | /** 4 | * @method filterAdditionalTypes 5 | * @param {Array} additionalTypes 6 | * @param {String} - additionalTypes[].type a name of type (links...) 7 | * @param {String} - additionalTypes[].name a name of the entity 8 | * @param {Object} - additionalTypes[].params options to request 9 | * @return {Array} 10 | */ 11 | const filterAdditionalTypes = (additionalTypes = []) => { 12 | return additionalTypes.filter(typeObj => { 13 | const type = typeObj.type || '' 14 | return ALLOWED_ADDITIONAL_TYPES.includes(type) 15 | }) 16 | } 17 | 18 | module.exports = filterAdditionalTypes 19 | -------------------------------------------------------------------------------- /utils/get-client-options.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @method getClientOptions 3 | * @param {String} language 4 | * @param {Object} options { version: String } 5 | * @return {Object} 6 | */ 7 | const getClientOptions = (language = '', options = {}) => { 8 | if (language.length > 0) { 9 | options.starts_with = language + (options.starts_with || '*') 10 | } 11 | 12 | return options 13 | } 14 | 15 | module.exports = getClientOptions 16 | -------------------------------------------------------------------------------- /utils/get-languages.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @method getLanguages 3 | * @param {Array} space language_codes from Space Object from Storyblok API 4 | * @return {Array} can be [''] (only one language) or ['', 'pt/*'] with two or more languages 5 | */ 6 | const getLanguages = (codes = []) => { 7 | return [ 8 | ...codes.map(lang => lang + '/'), 9 | '' // default languages does not need transform path 10 | ] 11 | } 12 | 13 | module.exports = getLanguages 14 | -------------------------------------------------------------------------------- /utils/get-path.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @method getPath 3 | * @param {String} entity 4 | * @return {String} 5 | */ 6 | const getPath = (entity = '') => `cdn/${entity}` 7 | 8 | module.exports = getPath 9 | -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | const getPath = require('./get-path') 2 | const loadData = require('./load-data') 3 | const getLanguages = require('./get-languages') 4 | const createSchema = require('./create-schema') 5 | const processImage = require('./process-image') 6 | const createDirectory = require('./create-directory') 7 | const transformStory = require('./transform-story') 8 | const filterAdditionalTypes = require('./filter-additional-types') 9 | 10 | /** 11 | * @method isStoriesContent 12 | * @param {Object} entity { type: String } 13 | * @return {Boolean} 14 | */ 15 | const isStoriesContent = entity => entity.type === 'stories' 16 | 17 | /** 18 | * @method 19 | * @param {StoryblokClient} client StoryblokClient instance 20 | * @param {String} entity 21 | * @param {Object} storyBlokOptions 22 | * @return {Array} 23 | */ 24 | const loadAllData = async (client, entity, storyBlokOptions, language) => { 25 | let page = 1 26 | let res = await loadData(client, entity, page, storyBlokOptions, language) 27 | const all = res.data[entity] 28 | const total = res.total 29 | const lastPage = Math.ceil((total / storyBlokOptions.per_page)) 30 | 31 | while (page < lastPage) { 32 | page++ 33 | res = await loadData(client, entity, page, storyBlokOptions, language) 34 | res.data[entity].forEach(story => { 35 | all.push(story) 36 | }) 37 | } 38 | 39 | if (entity === 'stories') { 40 | // only transform stories to prevent id conflicts 41 | return all.map(story => transformStory(story)) 42 | } 43 | 44 | return all 45 | } 46 | 47 | /** 48 | * @method getSpace 49 | * @param {StoryblokClient} client StoryblokClient instance 50 | * @return {Object} Storyblok space object 51 | */ 52 | const getSpace = async client => { 53 | const res = await client.get('cdn/spaces/me') 54 | 55 | return res.data.space || {} 56 | } 57 | 58 | /** 59 | * @method processStoriesData 60 | * @param {Object} store Gridsome Data Store API 61 | * @param {StoryblokClient} client client StoryblokClient instance 62 | * @param {Object} entity { type: String, name: String } 63 | * @param {Object} storyBlokOptions params to StoryblokClient 64 | */ 65 | const processData = async (collection, client, entity, storyBlokOptions) => { 66 | const data = await loadAllData(client, entity.type, { 67 | per_page: 1000, 68 | ...storyBlokOptions 69 | }) 70 | 71 | for (const value of Object.values(data)) { 72 | collection.addNode({ 73 | ...value 74 | }) 75 | } 76 | } 77 | 78 | /** 79 | * @method processStoriesData 80 | * @param {Object} store Gridsome Data Store API 81 | * @param {StoryblokClient} client client StoryblokClient instance 82 | * @param {Object} entity { type: String, name: String } 83 | * @param {Object} storyBlokOptions params to StoryblokClient 84 | */ 85 | const processTagData = async (collection, client, entity, storyBlokOptions) => { 86 | const data = await loadAllData(client, entity.type, { 87 | per_page: 1000, 88 | ...storyBlokOptions 89 | }) 90 | 91 | for (const value of Object.values(data)) { 92 | const { name } = value 93 | collection.addNode({ 94 | ...value, 95 | id: name 96 | }) 97 | } 98 | } 99 | 100 | /** 101 | * @method processStoriesData 102 | * @param {Object} store Gridsome Data Store API 103 | * @param {StoryblokClient} client client StoryblokClient instance 104 | * @param {Object} entity { type: String, name: String } 105 | * @param {Object} storyBlokOptions params to StoryblokClient 106 | * @param {String} language language string defined in Storyblok 107 | * @param {Object} pluginOptions plugin options { downloadImages } 108 | */ 109 | const processStoriesData = async (collection, client, entity, storyBlokOptions, language = '', pluginOptions = {}) => { 110 | const data = await loadAllData(client, entity.type, { 111 | per_page: 1000, 112 | ...storyBlokOptions 113 | }, language) 114 | 115 | for (let value of Object.values(data)) { 116 | if (isStoriesContent(entity) && pluginOptions.downloadImages) { 117 | console.log(`[gridsome-source-storyblok] Processing story ${value.name} to search images and download them...`) 118 | try { 119 | value = await processImage(pluginOptions, value) 120 | } catch (e) { 121 | console.error('[gridsome-source-storyblok] [gridsome-source-storyblok] Error on process story to download images: ' + e.message) 122 | } 123 | } 124 | 125 | collection.addNode({ 126 | ...value 127 | }) 128 | } 129 | } 130 | 131 | module.exports = { 132 | getPath, 133 | getSpace, 134 | loadData, 135 | loadAllData, 136 | processData, 137 | getLanguages, 138 | createSchema, 139 | createDirectory, 140 | transformStory, 141 | processTagData, 142 | processStoriesData, 143 | filterAdditionalTypes 144 | } 145 | -------------------------------------------------------------------------------- /utils/load-data.js: -------------------------------------------------------------------------------- 1 | const getPath = require('./get-path') 2 | const getClientOptions = require('./get-client-options') 3 | 4 | /** 5 | * @method 6 | * @param {StoryblokClient} client StoryblokClient instance 7 | * @param {Int} page 8 | * @param {String} entity 9 | * @param {Object} options 10 | * @return {Promise} StoryblokResponse object { data: { stories: [] }, total, perPage } 11 | */ 12 | const loadData = (client, entity, page, options, language) => { 13 | const path = getPath(entity) 14 | const _options = getClientOptions(language || '', { ...options, page }) 15 | 16 | return client.get(path, _options) 17 | } 18 | 19 | module.exports = loadData 20 | -------------------------------------------------------------------------------- /utils/process-image.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const https = require('https') 3 | const fs = require('fs') 4 | const { isArray, isPlainObject, isString } = require('lodash') 5 | 6 | const { PLUGIN_ROOT, SOURCE_ROOT } = require('./constants') 7 | 8 | /** 9 | * @method isStoryblokImage 10 | * @param {String} value 11 | * @return {Boolean} 12 | */ 13 | const isStoryblokImage = value => { 14 | const isStoryblokPath = value.indexOf('//a.storyblok.com/f/') !== -1 15 | const isImagePath = value.match(/\.(jpeg|jpg|gif|png)$/) !== null 16 | 17 | return isStoryblokPath && isImagePath 18 | } 19 | 20 | /** 21 | * @method getImageUrl 22 | * @param {String} value 23 | * @return {String} 24 | * 25 | * @example 26 | * getImageUrl('https://any-url.com') // 'https://any-url.com' 27 | * getImageUrl('http://any-url.com') // 'http://any-url.com' 28 | * getImageUrl('//any-url.com') // 'https://any-url.com' 29 | */ 30 | const getImageUrl = value => /^https?:/.test(value) ? value : `https:${value}` 31 | 32 | /** 33 | * @method downloadImage 34 | * @param {String} url 35 | * @param {String} filePath 36 | * @param {String} filename 37 | * @return {Promise} 38 | */ 39 | const downloadImage = (url, filePath, filename) => { 40 | if (fs.existsSync(filePath)) { 41 | console.log(`[gridsome-source-storyblok] Image ${filename} already downloaded`) 42 | return 43 | } 44 | 45 | const URL = getImageUrl(url) 46 | return new Promise((resolve, reject) => { 47 | console.log(`[gridsome-source-storyblok] Downloading: ${filename}...`) 48 | const file = fs.createWriteStream(filePath) 49 | 50 | https.get(URL, response => { 51 | response.pipe(file) 52 | file.on('finish', () => { 53 | console.log(`[gridsome-source-storyblok] ${filename} successfully downloaded!`) 54 | file.close(resolve) 55 | }) 56 | }).on('error', err => { 57 | console.error(`[gridsome-source-storyblok] Error on processing image ${filename}`) 58 | console.error(err.message) 59 | fs.unlink(filePath, err => { 60 | if (err) { 61 | reject(err) 62 | } 63 | 64 | console.log(`[gridsome-source-storyblok] Removed the ${filename} image correct`) 65 | resolve(true) 66 | }) 67 | }) 68 | }) 69 | } 70 | 71 | /** 72 | * @method getPathToSave 73 | * @param {String} dir 74 | * @param {String} filename 75 | * @return {String} returns a absolute path to file that will be save 76 | */ 77 | const getPathToSave = (dir, filename) => { 78 | return path.join(PLUGIN_ROOT, SOURCE_ROOT, dir, filename) 79 | } 80 | 81 | /** 82 | * @method getFilename 83 | * @param {String} url 84 | * @return {String} 85 | */ 86 | const getFilename = url => url.substring(url.lastIndexOf('/') + 1) 87 | 88 | /** 89 | * @method getOptionsFromImage 90 | * @param {String} imageDirectory 91 | * @param {String} imageURL Storyblok image URL 92 | * @return {Object} { url, filePath, path, filename } 93 | */ 94 | const getOptionsFromImage = (imageDirectory, imageURL) => { 95 | const url = imageURL 96 | const filename = getFilename(imageURL) 97 | const filePath = getPathToSave(imageDirectory, filename) 98 | const path = `${imageDirectory}/${filename}` 99 | 100 | return { 101 | url, 102 | filePath, 103 | path, 104 | filename 105 | } 106 | } 107 | 108 | /** 109 | * @method processItem 110 | * @param {String} imageDirectory directory to save the image 111 | * @param {Object} item object from Content Delivery API 112 | * @return {Promise} return the same object 113 | */ 114 | const processItem = async (imageDirectory, item) => { 115 | for (const key in item) { 116 | const value = item[key] 117 | 118 | if (isString(value)) { 119 | if (isStoryblokImage(value)) { 120 | try { 121 | const image = value 122 | const data = getOptionsFromImage(imageDirectory, image) 123 | const { url, filePath, path, filename } = data 124 | item[key] = { 125 | url, 126 | filename, 127 | path 128 | } 129 | await downloadImage(url, filePath, filename) 130 | } catch (e) { 131 | console.error('[gridsome-source-storyblok] Error on download image ' + e.message) 132 | } 133 | } 134 | } 135 | 136 | if (isArray(value)) { 137 | try { 138 | await Promise.all( 139 | value.map(_item => processItem(imageDirectory, _item)) 140 | ) 141 | } catch (e) { 142 | console.error(e) 143 | } 144 | } 145 | 146 | if (isPlainObject(value)) { 147 | try { 148 | await processItem(imageDirectory, value) 149 | } catch (e) { 150 | console.error(e) 151 | } 152 | } 153 | } 154 | 155 | return Promise.resolve(item) 156 | } 157 | 158 | /** 159 | * @method processImage 160 | * @param {Object} options { imageDirectory: String } 161 | * @param {Object} story Storyblok story content 162 | * @return {Promise} 163 | */ 164 | const processImage = async (options, story) => { 165 | const imageDirectory = options.imageDirectory 166 | try { 167 | const content = await processItem(imageDirectory, story.content) 168 | 169 | return Promise.resolve({ 170 | ...story, 171 | content 172 | }) 173 | } catch (e) { 174 | return Promise.reject(e) 175 | } 176 | } 177 | 178 | module.exports = processImage 179 | -------------------------------------------------------------------------------- /utils/transform-story.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @method transformStory 3 | * @param {Object} story Storyblok Story Object 4 | * @return {Object} rewrited id field to prevent id conflicts 5 | */ 6 | const transformStory = (story = {}) => { 7 | const { id, lang, path } = story 8 | delete story.path 9 | 10 | return { 11 | ...story, 12 | real_path: path, 13 | id: `story-${id}-${lang}` 14 | } 15 | } 16 | 17 | module.exports = transformStory 18 | --------------------------------------------------------------------------------