├── .gitignore ├── index.js ├── package.json ├── readme.md └── src ├── data.js ├── plugin ├── index.js ├── liquid.js └── nunjuks.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | .DS_Store -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Node Modules 2 | const StoryblokTo11tyPlugin = require('./src/plugin/index') 3 | const StoryblokTo11tyData = require('./src/data') 4 | 5 | /** 6 | * StoryblokTo11ty is the main class that fetches the data 7 | * from Storyblok 8 | */ 9 | module.exports = { 10 | importer: StoryblokTo11tyData, 11 | plugin: StoryblokTo11tyPlugin 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storyblok-11ty", 3 | "version": "1.6.0", 4 | "description": "Import Stories and Datasources from Storyblok to 11ty as data objects or static files and it adds custom tags for blocks parsing.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [ 10 | "Storyblok", 11 | "11ty" 12 | ], 13 | "deprecated": false, 14 | "author": { 15 | "name": "Christian Zoppi", 16 | "email": "me@christianzoppi.com" 17 | }, 18 | "license": "MIT", 19 | "dependencies": { 20 | "axios": "^0.21.1", 21 | "storyblok-js-client": "^3.3.1" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/christianzoppi/storyblok-11ty" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/christianzoppi/storyblok-11ty/issues" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Import data from Storyblok to 11ty 2 | 3 | Import Stories and Datasources from [Storyblok](https://www.storyblok.com/) and use them with [11ty](https://www.11ty.dev/) for your static website. 4 | 5 | You can download the data and store it ast front matter templates or as global data objects. 6 | 7 | This package will work also as [Eleventy plugin](#eleventy-plugin) to add a custom tag for liquid and nunjucks to make it easier to render Storyblok blocks. I'm working also to add such a feature to Javascript templates and to EJS. 8 | 9 | ## Importer 10 | 11 | ### Class `StoryblokTo11ty.importer` 12 | 13 | **Parameters** 14 | 15 | - `config` Object 16 | - `token` String, The preview token you can find in your space dashboard at https://app.storyblok.com 17 | - `[version]` String, optional, defaults to `draft`. It's the Storyblok content version. It can be `published` or `draft` 18 | - `[layouts_path]` String, optional, defaults to empty string. It's the main path of your layouts in 11ty 19 | - `[stories_path]` String, optional, defaults to `storyblok`. It's the folder where the front matter files are stored 20 | - `[datasources_path]` String, optional, defaults to `_data`. It's the folder where the global data files are stored 21 | - `[components_layout]` Object, optional, defaults to empty object. An object with parameter -> value to match specific component to specific layouts. For example `{root: 'layouts/root.ejs', news: 'layouts/news_entry.ejs'}`. The script will use the name of the component as default layout for each entry. An entry made with the `root` component will have by default `layouts/root`; 22 | - `[storyblok_client_config]` Object, optional, defaults to empty object. You can pass a custom object of settings for the [config parameter](https://github.com/storyblok/storyblok-js-client#class-storyblok) of the Storyblok JS Client. 23 | 24 | ### Stories Data Transformation 25 | Stories are fetched from Storyblok api and the `content` propert of objects is renamed as `data` because using just `content` won't work well with 11ty. The story object will have 3 new properties used by 11ty: 26 | - `layout` String. The name of the folder inside `_include` where you have stored your layouts; 27 | - `tags` String. The name of the component of the entry, used to support the *collections* feature of 11ty; 28 | - `permalink` String. The permalink of the entry. This value uses the `real path` if set, otherwise it falls back to the `full slug`. 29 | 30 | ### Method `StoryblokTo11ty#getStories` 31 | 32 | With this method you can get all the stories from your space as an array of objects. Stories are [transformed](#stories-data-transformation) in order to let you use layouts and permalinks. 33 | 34 | **Parameters** 35 | - `[options]` Object, optional. 36 | - [options.component] String, optional. Set this parameter with the name of a component to get just the entries made with it 37 | - [options.resolve_relations] String, optional. Resolve multiple levels of content by specifying comma-separated values of `component.field_name` according to your data model. 38 | 39 | **Return** 40 | Promise. The response of the promise is an array of transformed entries. 41 | 42 | **Examples** 43 | 44 | ```javascript 45 | // Example of Global Data File in the _data directory 46 | module.exports = async () => { 47 | const StoryblokTo11ty = require('storyblok-11ty'); 48 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 49 | 50 | return await sb.getStories(); 51 | } 52 | ``` 53 | 54 | ```javascript 55 | // Alternative example to return just the pages made with the component called news 56 | module.exports = async () => { 57 | const StoryblokTo11ty = require('storyblok-11ty'); 58 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 59 | 60 | return await sb.getStories('news'); 61 | } 62 | ``` 63 | 64 | ### Method `StoryblokTo11ty#storeStories` 65 | 66 | With this method you can store all the stories from your space as front matter .md files. Stories are [transformed](#stories-data-transformation) in order to let you use layouts and permalinks. 67 | 68 | **Parameters** 69 | - `[options]` Object, optional. 70 | - [options.component] String, optional. Set this parameter with the name of a component to get just the entries made with it 71 | 72 | **Return** 73 | Promise. Return `false` if something went wrong in the process, otherwise `true`. 74 | 75 | **Examples** 76 | 77 | ```javascript 78 | // Storing all of the entries 79 | const StoryblokTo11ty = require('storyblok-11ty'); 80 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 81 | 82 | sb.storeStories(); 83 | ``` 84 | 85 | ```javascript 86 | // Storing just the news 87 | const StoryblokTo11ty = require('storyblok-11ty'); 88 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 89 | 90 | sb.storeStories('news'); 91 | ``` 92 | 93 | ### Method `StoryblokTo11ty#getDatasources` 94 | 95 | With this method you can get all the datasources or one in particular as an array of objects. For each datasource the script will retrieve all the dimensions. 96 | 97 | **Parameters** 98 | - `[datasource_slug]` String, optional. The slug of the datasource you want to retrieve. 99 | 100 | **Return** 101 | Promise. The response of the promise is an object with all the datasources or an array of entries in case you are requesting a single datasource. 102 | 103 | **Examples** 104 | 105 | ```javascript 106 | // Example of Global Data File in the _data directory 107 | module.exports = async () => { 108 | const StoryblokTo11ty = require('storyblok-11ty'); 109 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 110 | 111 | return await sb.getDatasources(); 112 | } 113 | ``` 114 | 115 | ```javascript 116 | // Alternative example to return just the datasource called categories 117 | module.exports = async () => { 118 | const StoryblokTo11ty = require('storyblok-11ty'); 119 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 120 | 121 | return await sb.getDatasources('categories'); 122 | } 123 | ``` 124 | 125 | ### Method `StoryblokTo11ty#storeDatasources` 126 | 127 | With this method you can get all the datasources or one in particular. The datasources will be stored as `json` files in the `_data` folder or in the one specified through the `datasources_path` parameter of the `Storyblok11Ty` instance. Each datasource will be stored in a file with its name and in case you are requesting all of the datasources the name of the file will be `datasources.json`. 128 | 129 | **Parameters** 130 | - `[datasource_slug]` String, optional. The slug of the datasource you want to retrieve. 131 | 132 | **Return** 133 | Promise. Return `false` if something went wrong in the process, otherwise `true`. 134 | 135 | **Examples** 136 | 137 | ```javascript 138 | // Store all of the datasources in a single datasources.json file 139 | const StoryblokTo11ty = require('storyblok-11ty'); 140 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 141 | 142 | return await sb.storeDatasources(); 143 | ``` 144 | 145 | ```javascript 146 | // Storing the datasource called categories in categories.json 147 | const StoryblokTo11ty = require('storyblok-11ty'); 148 | const sb = new StoryblokTo11ty.importer({token: 'your-space-token'}); 149 | 150 | sb.storeDatasources('categories'); 151 | ``` 152 | 153 | ## Eleventy Plugin 154 | 155 | ### Class `StoryblokTo11ty.plugin` 156 | 157 | **Parameters** 158 | 159 | - `config` Object 160 | - `blocks_folder` String, The folder of the blocks layouts. It should include the *includes* folder path just if you are using Nunjucks. 161 | 162 | ```javascript 163 | // Example of Global Data File in the _data directory 164 | const StoryblokTo11ty = require('storyblok-11ty'); 165 | const sb = new StoryblokTo11ty.plugin({blocks_folder: 'components/'}); 166 | ``` 167 | 168 | ### Custom tag for blocks fields 169 | If you have a field of type `block` and you have several blocks inside it, you might want to output all of them using a different layout file for each block. 170 | In order to achieve this you can use a custom tag for Liquid and Nunjucks layouts. 171 | 172 | #### sb_blocks for Liquid 173 | The custom tag `sb_blocks` can be used like this `{% sb_blocks name_of_blocks_field %}` and it will loop through all the blocks inside the field. For each block it'll include a template with the same name as the slugified component name. If your block is called `Home Banner` the tag will look for the template `home-banner.liquid` inside the `_includes/block/` folder or inside `includes/your_custom_folder/`. You can specify `your_custom_folder` passing the parameter `blocks_folder` to the Storyblok11Ty instance like in the example below. You don't need to add your *includes* folder path into the `blocks_folder` parameter because 11ty will take care of that for you. 174 | 175 | The block fields will be passed to the layout under the object `block`. If your block has a field called `heading` you can retrieve its value referencing to it as `block.heading`. 176 | 177 | 178 | ```javascript 179 | const Storyblok11Ty = require("storyblok-11ty"); 180 | const sbto11ty = new Storyblok11Ty.plugin({blocks_folder: 'components/'}); 181 | 182 | module.exports = function(eleventyConfig) { 183 | eleventyConfig.addPlugin(sbto11ty); 184 | }; 185 | ``` 186 | 187 | #### sb_blocks for Nunjucks 188 | The custom tag `sb_blocks` can be used like this `{% sb_blocks name_of_blocks_field %}` and it will loop through all the blocks inside the field. For each block it'll include a template with the same name as the slugified component name. If your block is called `Home Banner` the tag will look for the template `home-banner.njk` inside the `_includes/block/` folder or inside `includes/your_custom_folder/`. You must specify `your_custom_folder` passing the parameter `blocks_folder` to the Storyblok11Ty instance like in the example below. You must add your *includes* folder path into the `blocks_folder` parameter to make the tag work properly, unfortunately it's not the same as for Liquid. 189 | 190 | The block fields will be passed to the layout under the object `block`. If your block has a field called `heading` you can retrieve its value referencing to it as `block.heading`. 191 | 192 | 193 | ```javascript 194 | const Storyblok11Ty = require("storyblok-11ty"); 195 | const sbto11ty = new Storyblok11Ty.plugin({blocks_folder: '_includes/components/'}); 196 | 197 | module.exports = function(eleventyConfig) { 198 | eleventyConfig.addPlugin(sbto11ty); 199 | }; 200 | ``` 201 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | // Node Modules 2 | const StoryblokClient = require('storyblok-js-client') 3 | const fs = require('fs') 4 | 5 | /** 6 | * StoryblokTo11ty is the main class that fetches the data 7 | * from Storyblok 8 | */ 9 | class StoryblokTo11tyData { 10 | /** 11 | * Constructor 12 | * @param {object} params The params for initialising the class. 13 | * @param {string} params.token The API token of the Storyblok space. 14 | * @param {string} [params.version=draft] The version of the api to fetch (draft or public). 15 | * @param {string} [params.layouts_path=''] The path to the layouts folder in 11ty. 16 | * @param {string} [params.stories_path=./storyblok/] The path where to store the entries. 17 | * @param {string} [params.stories_path=./_data/] The path where to store the datasources. 18 | * @param {string} [params.components_layouts_map] An object with parameter -> value to match specific component to specific layouts. 19 | * @param {object} [params.storyblok_client_config] The config for the Storyblok JS client. 20 | */ 21 | constructor(params = {}) { 22 | this.api_version = params.version || 'draft' 23 | this.storyblok_api_token = params.token 24 | this.stories_path = this.cleanPath(params.stories_path || 'storyblok') 25 | this.datasources_path = this.cleanPath(params.datasources_path || '_data') 26 | this.layouts_path = params.layouts_path || '' 27 | this.components_layouts_map = params.components_layouts_map || {} 28 | this.per_page = 100 29 | this.stories = [] 30 | this.storyblok_client_config = params.storyblok_client_config || {} 31 | 32 | // Init the Storyblok client 33 | if (this.storyblok_api_token || (this.storyblok_client_config && this.storyblok_client_config.accessToken)) { 34 | if (!this.storyblok_client_config.accessToken) { 35 | this.storyblok_client_config.accessToken = this.storyblok_api_token 36 | } 37 | // Setting up cache settings if not specified 38 | if (!this.storyblok_client_config.hasOwnProperty('cache')) { 39 | this.storyblok_client_config.cache = { 40 | clear: 'auto', 41 | type: 'memory' 42 | } 43 | } 44 | this.client = new StoryblokClient(this.storyblok_client_config) 45 | } 46 | } 47 | 48 | /** 49 | * Takes care of cleaning a path set by the user removing 50 | * leading and trailing slashes and add the process cwd 51 | * @param {string} path The path string. 52 | * @return {string} the cleaned path 53 | */ 54 | cleanPath(path) { 55 | path = path ? `/${path.replace(/^\/|\/$/g, '')}` : '' 56 | return `${process.cwd()}${path}/` 57 | } 58 | 59 | /** 60 | * Get data of a single datasource retrieving a specific dimension or all of them 61 | * @param {string} slug Name of the datasource. 62 | * @param {string} [diension_name] The name of the dimension. 63 | * @return {promise} An array with the datasource entries. 64 | */ 65 | async getDatasource(slug, dimension_name) { 66 | let request_options = { query: { datasource: slug } } 67 | if (typeof dimension_name === 'undefined') { 68 | // Get all the dimensions names of a datasource, then we'll request each 69 | // individual dimension data. 70 | let data = [] 71 | let dimensions = [''] 72 | let datasource_info = null 73 | // Getting data of this datasource 74 | datasource_info = await this.getData(`datasources/${slug}`, 'datasource') 75 | if (!datasource_info.error && !datasource_info.data) { 76 | console.error(`Datasource with slug "${slug}" not found`) 77 | } 78 | if (datasource_info.error || !datasource_info.data) { 79 | return {} 80 | } 81 | // Getting the list of dimensions 82 | if (datasource_info.data[0] && datasource_info.data[0].dimensions) { 83 | dimensions = dimensions.concat(datasource_info.data[0].dimensions.map(dimension => dimension.entry_value)) 84 | } 85 | // Requesting the data of each individual datasource 86 | await Promise.all(dimensions.map(async (dimension) => { 87 | let dimension_entries = await this.getDatasource(slug, dimension) 88 | if (dimension_entries) { 89 | data = data.concat(dimension_entries) 90 | } 91 | })) 92 | // Returning the data 93 | return data 94 | } else { 95 | // If the dimension is not undefined, set the dimensino parameter in the query 96 | // The dimension can be empty in case it's the default dimension that you are 97 | // trying to retrieve. 98 | request_options.query.dimension = dimension_name 99 | } 100 | 101 | // Getting the entries of a datasource 102 | let datasource = await this.getData('datasource_entries', 'datasource_entries', request_options) 103 | if (datasource.error) { 104 | return false 105 | } else { 106 | return datasource.data 107 | } 108 | } 109 | 110 | /** 111 | * Get data of datasources. It can be single or multiple 112 | * @param {string} [slug] Name of the datasource. 113 | * @return {promise} An object with the data of the datasource/s requested. 114 | */ 115 | async getDatasources(slug) { 116 | let datasources = {} 117 | // If the slug is set, request a single datasource 118 | // otherwise get the index of datasources first 119 | if (slug) { 120 | return this.getDatasource(slug) 121 | } else { 122 | let request_options = { 123 | query: { 124 | per_page: this.per_page 125 | } 126 | } 127 | // Get the index of the datasources of the space 128 | let datasources_index = await this.getData('datasources', 'datasources', request_options) 129 | if (!datasources_index.data || datasources_index.error) { 130 | return [] 131 | } 132 | // Get the entries of each individual datasource 133 | await Promise.all(datasources_index.data.map(async (datasource) => { 134 | datasources[datasource.slug] = await this.getDatasource(datasource.slug) 135 | })) 136 | return datasources 137 | } 138 | } 139 | 140 | /** 141 | * Store a datasource to a json file 142 | * @param {string} [slug] Name of the datasource. 143 | * @return {bool} True or false depending if the script was able to store the data 144 | */ 145 | async storeDatasources(slug) { 146 | let data = await this.getDatasources(slug) 147 | // If the data is empty, it won't save the file 148 | if ((Array.isArray(data) && !data.length) || (!Array.isArray(data) && !Object.keys(data).length)) { 149 | return false 150 | } 151 | // Creating the cache path if it doesn't exist 152 | if (!fs.existsSync(this.datasources_path)) { 153 | fs.mkdirSync(this.datasources_path, { recursive: true }) 154 | } 155 | // If it's not a specific datasource, the filename will be "datasources" 156 | let filename = slug || 'datasources' 157 | // Storing entries as json front matter 158 | try { 159 | fs.writeFileSync(`${this.datasources_path}${filename}.json`, JSON.stringify(data, null, 4)) 160 | console.log(`Datasources saved in ${this.datasources_path}`) 161 | return true 162 | } catch (err) { 163 | return false 164 | } 165 | } 166 | 167 | /** 168 | * Transforms a story based on the params provided 169 | * @param {object} story The story that has to be transformed. 170 | * @return {object} The transformed story. 171 | */ 172 | transformStories(story) { 173 | // Setting the path 174 | story.layout = `${this.layouts_path.replace(/^\/|\/$/g, '')}/` 175 | // Setting the collection 176 | story.tags = story.content.component 177 | story.data = Object.assign({}, story.content) 178 | delete story.content 179 | // Adding template name 180 | story.layout += this.components_layouts_map[story.data.component] || story.data.component 181 | // Creating the permalink using the story path override field (real path in Storyblok) 182 | // or the full slug 183 | story.permalink = `${(story.path || story.full_slug).replace(/\/$/, '')}/` 184 | return story 185 | } 186 | 187 | /** 188 | * Get all the stories from Storyblok 189 | * @param {object} [params] Filters for the stories request. 190 | * @param {string} [params.component] Name of the component. 191 | */ 192 | async getStories(params) { 193 | let request_options = { 194 | query: { 195 | version: this.api_version, 196 | per_page: this.per_page 197 | } 198 | } 199 | // Filtering by component 200 | if (params?.component) { 201 | request_options.query['filter_query[component][in]'] = params.component 202 | } 203 | // Whether to resolve relations 204 | if (params?.resolve_relations) { 205 | request_options.query['resolve_relations'] = params.resolve_relations 206 | } 207 | // Whether to resolve relations 208 | if (params?.resolve_links) { 209 | request_options.query['resolve_links'] = params.resolve_links 210 | } 211 | // Whether to resolve relations 212 | if (params?.language) { 213 | request_options.query['language'] = params.language 214 | } 215 | // Whether to resolve relations 216 | if (params?.language) { 217 | request_options.query['fallback_lang'] = params.fallback_lang 218 | } 219 | // Getting the data 220 | let pages = await this.getData('stories', 'stories', request_options) 221 | if (!pages.data || pages.error) { 222 | return [] 223 | } 224 | // Returning the transformed stories 225 | return pages.data.map(story => this.transformStories(story)) 226 | } 227 | 228 | /** 229 | * Cache stories in a folder as json files 230 | * @param {object} [params] Filters for the stories request. 231 | * @param {string} [params.component] Name of the component. 232 | * @return {bool} True or false depending if the script was able to store the data 233 | */ 234 | async storeStories(params) { 235 | let stories = await this.getStories(params) 236 | // Creating the cache path if it doesn't exist 237 | if (!fs.existsSync(this.stories_path)) { 238 | fs.mkdirSync(this.stories_path, { recursive: true }) 239 | } 240 | // Storing entries as json front matter 241 | try { 242 | stories.forEach(story => { 243 | fs.writeFileSync(`${this.stories_path}${story.uuid}.md`, `---json\n${JSON.stringify(story, null, 4)}\n---`) 244 | }) 245 | console.log(`${stories.length} stories saved in ${this.stories_path}`) 246 | return true 247 | } catch (err) { 248 | console.error(err) 249 | return false 250 | } 251 | } 252 | 253 | /** 254 | * Get a page of data from Storyblok API. 255 | * @param {string} endpoint The endpoint to query. 256 | * @param {string} entity_name The name of the entity to be retrieved from the api response. 257 | * @param {object} [params] Parameters to add to the API request. 258 | * @return {promise} The data fetched from the API. 259 | */ 260 | getData(endpoint, entity_name, params) { 261 | return new Promise(async resolve => { 262 | let data = [] 263 | let data_requests = [] 264 | // Paginated request vs single request 265 | if (params?.query?.per_page) { 266 | // Paginated request 267 | params.query.page = 1 268 | // Get the first page to retrieve the total number of entries 269 | let first_page = null 270 | try { 271 | first_page = await this.apiRequest(endpoint, params) 272 | } catch (err) { 273 | return resolve({ error: true, message: err }) 274 | } 275 | if (!first_page?.data) { 276 | return resolve({ data: [] }) 277 | } 278 | data = data.concat(first_page.data[entity_name]) 279 | // Getting the stories 280 | let total_entries = first_page.headers.total 281 | let total_pages = Math.ceil(total_entries / this.per_page) 282 | // The script will request all the pages of entries at the same time 283 | for (let page_index = 2; page_index <= total_pages; page_index++) { 284 | params.query.page = page_index 285 | data_requests.push(this.apiRequest(endpoint, params)) 286 | } 287 | } else { 288 | // Single request 289 | data_requests.push(this.apiRequest(endpoint, params)) 290 | } 291 | // When all the pages of entries are retrieved 292 | Promise.all(data_requests) 293 | .then(values => { 294 | // Concatenating the data of each page 295 | values.forEach(response => { 296 | if (response.data) { 297 | data = data.concat(response.data[entity_name]) 298 | } 299 | }) 300 | resolve({ data }) 301 | }) 302 | .catch(err => { 303 | // Returning an object with an error property to let 304 | // any method calling this one know that something went 305 | // wrong with the api request 306 | resolve({ error: true, message: err }) 307 | }) 308 | }) 309 | } 310 | 311 | /** 312 | * Get a page of stories from Storyblok 313 | * @param {int} page_index The index of the current page you want to retrieve. 314 | * @param {string} endpoint The endpoint to query. 315 | * @param {object} [params] Parameters to add to the API request. 316 | * @param {object} [params.query] Object with optional parameters for the API request. 317 | * @return {promise} The data fetched from the API. 318 | */ 319 | apiRequest(endpoint, params) { 320 | // Storyblok query options 321 | let request_options = {} 322 | // Adding the optional query filters 323 | if (params && params.query) { 324 | Object.assign(request_options, params.query) 325 | } 326 | // API request 327 | return new Promise((resolve, reject) => { 328 | this.client.get(`cdn/${endpoint}`, request_options) 329 | .then(response => { 330 | // Returning the response from the endpoint 331 | resolve(response) 332 | }) 333 | .catch(err => { 334 | // Error handling 335 | // Returning custom errors for 401 and 404 because they might 336 | // be the most common 337 | switch (err.response.status) { 338 | case 401: 339 | console.error('\x1b[31mStoryblokTo11ty - Error 401: Unauthorized. Probably the API token is wrong.\x1b[0m') 340 | break 341 | case 404: 342 | console.error('\x1b[31mStoryblokTo11ty - Error 404: The item you are trying to get doesn\'t exit.\x1b[0m') 343 | break 344 | default: 345 | console.error(`\x1b[31mStoryblokTo11ty - Error ${err.response.status}: ${err.response.statusText}`) 346 | break 347 | } 348 | reject(err) 349 | }) 350 | }) 351 | } 352 | } 353 | 354 | module.exports = StoryblokTo11tyData 355 | -------------------------------------------------------------------------------- /src/plugin/index.js: -------------------------------------------------------------------------------- 1 | // Node Modules 2 | const LiquidPlugin = require('./liquid') 3 | const NunjuksPlugin = require('./nunjuks') 4 | 5 | 6 | /** 7 | * StoryblokTo11ty is the main class that fetches the data 8 | * from Storyblok 9 | */ 10 | class StoryblokTo11tyPlugin { 11 | /** 12 | * Constructor 13 | * @param {object} params The params for initialising the class. 14 | * @param {string} params.blocks_folder The folder containing the templates of the blocks 15 | */ 16 | constructor(params = {}) { 17 | this.params = params 18 | } 19 | 20 | /** 21 | * Install the plugin into 11ty config 22 | */ 23 | configFunction(config) { 24 | let nunjuks = new NunjuksPlugin(config, this.params) 25 | nunjuks.addTags() 26 | 27 | let liquid = new LiquidPlugin(config, this.params) 28 | liquid.addTags() 29 | } 30 | } 31 | 32 | module.exports = StoryblokTo11tyPlugin 33 | -------------------------------------------------------------------------------- /src/plugin/liquid.js: -------------------------------------------------------------------------------- 1 | // Node Modules 2 | const utils = require('./../utils') 3 | const StoryblokClient = require('storyblok-js-client') 4 | const Storyblok = new StoryblokClient({}) 5 | 6 | /** 7 | * LiquidPlugin is the class to add custom tags for liquid templates 8 | */ 9 | class LiquidPlugin { 10 | /** 11 | * Constructor 12 | * @param {object} params The params for initialising the class. 13 | * @param {string} params.blocks_folder The folder containing the templates of the blocks 14 | */ 15 | constructor(config, params = {}) { 16 | this.params = params 17 | this.blocks_folder = params.blocks_folder ? `${params.blocks_folder.replace(/^\//g, '')}` : 'blocks/' 18 | this.config = config 19 | } 20 | 21 | /** 22 | * Install the tags 23 | */ 24 | addTags() { 25 | /** 26 | * LIQUID TAG FOR BLOCK LOOPING 27 | */ 28 | this.config.addLiquidTag("sb_blocks", (liquidEngine) => { 29 | return { 30 | parse: (tagToken) => { 31 | this.blocks = tagToken.args 32 | }, 33 | render: async (scope) => { 34 | // Getting the blocks array 35 | let blocks = liquidEngine.evalValue(this.blocks, scope) 36 | // Converting single object to array 37 | if (blocks && typeof blocks === 'object' && !Array.isArray(blocks)) { 38 | blocks = [blocks] 39 | } 40 | // Checking if blocks object is not set or is not an array 41 | if (!blocks || !Array.isArray(blocks)) { 42 | return '' 43 | } 44 | // Parsing each single block 45 | let html_output = '' 46 | for (let index = 0; index < blocks.length; index++) { 47 | let block = blocks[index] 48 | block.component = utils.slugify(block.component) 49 | let code = `{% include ${this.blocks_folder + block.component} %}` 50 | let tpl = liquidEngine.parse(code) 51 | let html = await liquidEngine.render(tpl, { block: block }) 52 | html_output += html 53 | } 54 | return Promise.resolve(html_output) 55 | } 56 | } 57 | }) 58 | 59 | this.config.addLiquidTag("sb_richtext", (liquidEngine) => { 60 | return { 61 | parse: (tagToken) => { 62 | this.data = tagToken.args 63 | }, 64 | render: async (scope) => { 65 | // Getting the blocks array 66 | let data = liquidEngine.evalValue(this.data, scope) 67 | 68 | // If it's already a string (in case previously this field was a textarea) 69 | if (typeof data === 'string') { 70 | return data 71 | } 72 | 73 | if (typeof data === 'undefined' || data === null) { 74 | return '' 75 | } 76 | 77 | let output = '' 78 | if (data.content && Array.isArray(data.content)) { 79 | try { 80 | output = Storyblok.richTextResolver.render(data) 81 | } catch (e) { 82 | output = '' 83 | } 84 | } 85 | return Promise.resolve(output) 86 | } 87 | } 88 | }) 89 | } 90 | } 91 | 92 | module.exports = LiquidPlugin 93 | -------------------------------------------------------------------------------- /src/plugin/nunjuks.js: -------------------------------------------------------------------------------- 1 | // Node Modules 2 | const utils = require('./../utils') 3 | const StoryblokClient = require('storyblok-js-client') 4 | const Storyblok = new StoryblokClient({}) 5 | 6 | /** 7 | * NunjucksPlugin is the class to add custom tags for nunjucks templates 8 | */ 9 | class NunjuksPlugin { 10 | /** 11 | * Constructor 12 | * @param {object} params The params for initialising the class. 13 | * @param {string} params.blocks_folder The folder containing the templates of the blocks 14 | */ 15 | constructor(config, params = {}) { 16 | this.params = params 17 | this.blocks_folder = params.blocks_folder ? `${params.blocks_folder.replace(/^\//g, '')}` : 'blocks/' 18 | this.config = config 19 | } 20 | 21 | /** 22 | * Output the content of an array of blocks 23 | * @param {array} blocks the array of blocks 24 | * @param {object} engine the nunjuks engine passed by Eleventy 25 | * @returns {string} the output 26 | */ 27 | outputBlocks(blocks, engine) { 28 | // Converting single object to array 29 | if (blocks && typeof blocks === 'object' && !Array.isArray(blocks)) { 30 | blocks = [blocks] 31 | } 32 | // Checking if blocks object is not set or is not an array 33 | if (!blocks || !Array.isArray(blocks)) { 34 | return '' 35 | } 36 | // Parsing each single block 37 | let html_output = '' 38 | blocks.forEach(block => { 39 | block.component = utils.slugify(block.component) 40 | let html = engine.render(`${this.blocks_folder + block.component}.njk`, { block: block }) 41 | html_output += html 42 | }) 43 | return html_output 44 | } 45 | 46 | /** 47 | * Install the tags 48 | */ 49 | addTags() { 50 | let self = this 51 | 52 | /** 53 | * NUNJUKS TAG FOR BLOCKS LOOPING 54 | */ 55 | this.config.addNunjucksTag("sb_blocks", (nunjucksEngine, nunjucksEnv) => { 56 | let self = this 57 | 58 | return new function () { 59 | this.tags = ["sb_blocks"] 60 | 61 | this.parse = function (parser, nodes, lexer) { 62 | let tok = parser.nextToken() 63 | 64 | let args = parser.parseSignature(null, true) 65 | parser.advanceAfterBlockEnd(tok.value) 66 | 67 | return new nodes.CallExtensionAsync(this, "run", args) 68 | } 69 | 70 | this.run = function (context, blocks, callback) { 71 | let html_output = self.outputBlocks(blocks, nunjucksEnv) 72 | return callback(null, html_output) 73 | } 74 | }() 75 | }) 76 | 77 | /** 78 | * NUNJUCKS TAG FOR RICH TEXT FIELD 79 | */ 80 | this.config.addNunjucksTag("sb_richtext", (nunjuksEngine, nunjucksEnv) => { 81 | let self = this 82 | // Defining custom rendering for blocks inside the editor 83 | Storyblok.setComponentResolver((component, block) => { 84 | let output = this.outputBlocks([block], nunjucksEnv) 85 | return output 86 | }) 87 | 88 | return new function () { 89 | this.tags = ["sb_richtext"] 90 | 91 | this.parse = function (parser, nodes, lexer) { 92 | let tok = parser.nextToken() 93 | 94 | let args = parser.parseSignature(null, true) 95 | parser.advanceAfterBlockEnd(tok.value) 96 | 97 | return new nodes.CallExtensionAsync(this, "run", args) 98 | } 99 | 100 | this.run = async function (context, data, callback) { 101 | 102 | // If it's already a string 103 | if (typeof data === 'string') { 104 | return data 105 | } 106 | 107 | if (typeof data === 'undefined' || data === null) { 108 | return '' 109 | } 110 | 111 | let output = '' 112 | if (data.content && Array.isArray(data.content)) { 113 | try { 114 | output = Storyblok.richTextResolver.render(data) 115 | } catch (e) { 116 | output = '' 117 | } 118 | } 119 | 120 | return callback(null, output) 121 | } 122 | }() 123 | 124 | }) 125 | } 126 | } 127 | 128 | module.exports = NunjuksPlugin 129 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | class Utils { 2 | static slugify(text) { 3 | return (text || '').toString().toLowerCase().trim() 4 | .replace(/\s+/g, '-') // Replace spaces with - 5 | .replace(/&/g, '-and-') // Replace & with 'and' 6 | .replace(/[^\w\-]+/g, '') // Remove all non-word chars 7 | .replace(/\-\-+/g, '-') // Replace multiple - with single - 8 | } 9 | } 10 | 11 | module.exports = Utils 12 | --------------------------------------------------------------------------------