├── .gitignore ├── .npmignore ├── README.md ├── bin └── contentful-static.js ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .tmp 2 | npm-debug.log 3 | .conf 4 | node_modules 5 | 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | npm-debug.log 4 | test 5 | Gruntfile.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple static site generator for contentful # 2 | 3 | ## What it's for ## 4 | A CLI tool to generate a site from templates + data from a contentful space 5 | 6 | ## How to use it ## 7 | 8 | To build a site from a space using the templates in `templatesFolder` 9 | ```sh 10 | contentful-static -a yourContentDeliveryApiAccessToken yourSpaceId templatesFolder/ dest/ 11 | ``` 12 | 13 | ## Template building 14 | 15 | Each entry in your contentful data is matched against a template by checking it's `contentType` 16 | name. 17 | 18 | ### Template variables 19 | 20 | Availiable in the template context are 21 | 22 | | Name | | 23 | |:------------|------------------------------------------| 24 | | entry | The entry for this template | 25 | | content | The entire contentful data object. | 26 | | entries | All entries | 27 | | includes | HTML data for all entries already rendered. Key is id. | 28 | | include(entry) | A function (shorthand for direct usage of include). Takes either a list of entries or an entry and returns it's html | 29 | | debug(obj) | Print debug for an object | 30 | 31 | ### A note on templates 32 | As a default `contentful-static` uses the template language [nunjucks](https://mozilla.github.io/nunjucks/). 33 | But since it uses [consolidate](https://www.npmjs.com/package/consolidate) in theory any other 34 | templating language can be used. 35 | 36 | ### Install with NPM ### 37 | 38 | ```sh 39 | npm install -g contentful-static 40 | ``` 41 | 42 | ## API 43 | 44 | ```js 45 | var contentfulStatic = require('contentful-static'); 46 | ``` 47 | 48 | ### 3. Configure ### 49 | 50 | ```js 51 | contentfulStatic.config({ 52 | // Path to templates. 53 | templates: 'templates', 54 | // Your Contentful space ID 55 | space: 'my12space34id', 56 | // Contentful Content Delivery API Access Token 57 | accessToken: '5fdae8a3myacc3sst0ken573962' 58 | }); 59 | ``` 60 | 61 | ### 4. Fetch ### 62 | 63 | ```js 64 | 65 | // With promise 66 | contentfulStatic.sync().then(function(json) { 67 | console.log('contentful-static: data stored successfully!', json); 68 | }, function (err) { 69 | console.log('contentful-static: data could not be fetched'); 70 | }); 71 | 72 | // With callback 73 | contentfulStatic.sync(function(err, json) { 74 | if(err) { 75 | console.log('contentful-static: data could not be fetched'); 76 | return false; 77 | } 78 | console.log('contentful-static: data fetched successfully!', json); 79 | }); 80 | ``` 81 | 82 | ### 4. Render ### 83 | 84 | ```js 85 | 86 | // With promise 87 | contentfulStatic.render(json).then(function(htmls) { 88 | // Rendered data is an object where key is entry sys id and value is its HTML 89 | console.log(htmls); 90 | }, function (err) { 91 | console.log('Could not render templates'); 92 | }); 93 | 94 | // With callback 95 | contentfulStatic.render(json, function(err, htmls) { 96 | // Handle callback 97 | }); 98 | ``` 99 | -------------------------------------------------------------------------------- /bin/contentful-static.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var yargs = require('yargs').usage('Usage: $0 -e [nunjucks] -h [host] -a [accessToken] -c [secure] ') 3 | .demand(3) 4 | .command('space', 'Contentful space or path to json file') 5 | .command('templates', 'Path to templates') 6 | .command('dest', 'Destination path') 7 | .help('h') 8 | .describe('e', 'Template engine') 9 | .default('e', 'nunjucks') 10 | .alias('e','engine') 11 | .describe('h', 'API host address') 12 | .alias('h', 'host') 13 | .describe('a', 'contentful access token') 14 | .alias('a', 'access') 15 | .describe('a', 'contentful access token') 16 | .alias('a', 'access') 17 | .describe('c', 'contentful security token') 18 | .alias('c', 'secure') 19 | .alias('h', 'help'); 20 | 21 | var argv = yargs.argv; 22 | 23 | var contentfulStatic = require('../'); 24 | var chalk = require('chalk'); 25 | var fs = require('fs'); 26 | var path = require('path'); 27 | var rimraf = require('rimraf'); 28 | var mkdirp = require('mkdirp'); 29 | var q = require('q'); 30 | 31 | 32 | var config = { 33 | host: argv.h || process.env.CONTENTFUL_HOST, 34 | space: argv._[0] || process.env.CONTENTFUL_SPACE, 35 | secure: argv.c || process.env.CONTENTFUL_SECURE, 36 | accessToken: argv.a || process.env.CONTENTFUL_ACCESS, 37 | engine: argv.e, 38 | templates: argv._[1] 39 | }; 40 | 41 | 42 | if (!config.accessToken) { 43 | console.error(chalk.red('No access token specified')); 44 | yargs.showHelp(); 45 | process.exit(); 46 | } 47 | contentfulStatic.config(config); 48 | 49 | 50 | var contentPromise; 51 | if (/\.json$/.test(argv._[0])) { 52 | try { 53 | content = JSON.parse(fs.readFileSync(argv._[0])); 54 | contentPromise = q.when(content); 55 | } catch (e) { 56 | console.log(chalk.red('Could not load contentful data')); 57 | console.error(e); 58 | process.exit(); 59 | } 60 | 61 | } else { 62 | contentfulStatic.config(config); 63 | contentPromise = contentfulStatic.sync(); 64 | } 65 | 66 | contentPromise.then(function(content) { 67 | console.log(chalk.green('Content fetched')); 68 | return contentfulStatic.render(content).then(function(byLocale) { 69 | console.log(chalk.green('Pages rendered')); 70 | // Clean build dir. 71 | var buildDir = argv._[2]; 72 | rimraf.sync(buildDir); 73 | mkdirp.sync(buildDir); 74 | 75 | // Start churning out pages by locale 76 | Object.keys(byLocale).forEach(function(code) { 77 | var includes = byLocale[code]; 78 | 79 | // TODO: some kind of config of what should be saved to file. 80 | content.entries[code].forEach(function(entry) { 81 | if (entry.fields.filepath) { 82 | var filepath = path.join(buildDir, code, entry.fields.filepath); 83 | mkdirp.sync(path.dirname(filepath)); 84 | fs.writeFileSync(filepath, includes[entry.sys.id]); 85 | console.log(chalk.cyan('Wrote ' + filepath)); 86 | } 87 | }); 88 | 89 | }); 90 | }); 91 | }).catch(function(err) { 92 | console.log(chalk.red("I'm sorry, something just went wrong.")); 93 | console.error(err); 94 | }); 95 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var 2 | contentful = require('contentful'), 3 | fs = require('fs'), 4 | mkdirp = require('mkdirp'), 5 | q = require('q'), 6 | chalk = require('chalk'), 7 | rimraf = require('rimraf'), 8 | consolidate = require('consolidate'), 9 | path = require('path'), 10 | merge = require('merge'), 11 | debuginfo = { 12 | renderCount: 0 13 | }, 14 | DEBUGMODE = false; 15 | 16 | 17 | function debug() { 18 | if(DEBUGMODE) { 19 | console.log(arguments); 20 | } 21 | } 22 | // check if object exists and throws an error if not 23 | function checkExistance(testObject, reference, throwException) { 24 | reference = 'ref: ' + reference; 25 | 26 | if(typeof testObject === 'string') { 27 | debug('checkExistance: Object is a string', testObject, reference); 28 | return 'string'; 29 | } 30 | var name = (testObject && testObject.fields && testObject.fields.name) ? testObject.fields.name : 'no name'; 31 | // console.log(name, reference); 32 | 33 | if(!testObject || typeof testObject === undefined || testObject === null) { 34 | var error = new Error('checkExistance failed: Object does not exist. ', testObject, reference); 35 | debug(error); 36 | return false; 37 | } 38 | return true; 39 | } 40 | 41 | module.exports = (function() { 42 | 43 | var contentTypes = { 44 | array: [], 45 | byId: {} 46 | }; 47 | 48 | var options = { 49 | engine: 'nunjucks', 50 | templates: 'templates', 51 | apiconfig: { 52 | space: null, 53 | accessToken: null, 54 | secure: true, 55 | host: 'cdn.contentful.com' 56 | }, 57 | context: { 58 | // context variables to pass into template rendering 59 | } 60 | }; 61 | 62 | var writeToFile = function() { 63 | var deferred = q.defer(); 64 | var filename = options.dest; 65 | var filepath = process.cwd() + '/' + filename; 66 | var directory = path.dirname(filepath); 67 | var contents = JSON.stringify(db, null, 2); 68 | 69 | mkdirp(directory, function(err) { 70 | if(err) { 71 | deferred.reject(err); 72 | return; 73 | } 74 | 75 | try { 76 | fs.writeFileSync(filepath, contents, 'utf8'); 77 | } catch (err) { 78 | deferred.reject(err); 79 | return; 80 | } 81 | deferred.resolve(); 82 | }); 83 | return deferred.promise; 84 | }; 85 | 86 | var contentfulStatic = { 87 | 88 | config: function( optionsObject ) { 89 | options.templates = optionsObject.templates || options.templates; 90 | options.engine = optionsObject.engine || options.engine; 91 | options.apiconfig.space = optionsObject.space || options.apiconfig.space; 92 | options.apiconfig.accessToken = optionsObject.accessToken || options.apiconfig.accessToken; 93 | options.apiconfig.secure = optionsObject.secure || options.apiconfig.secure; 94 | options.apiconfig.host = optionsObject.host || options.apiconfig.host; 95 | options.context = optionsObject.context || options.context; 96 | }, 97 | 98 | /** 99 | * Sync content from contentful. You can choose to supply a callback or just use the promise 100 | * that is returned. 101 | * 102 | * @param {Function} callback (optional) a callback function(err, content) 103 | * @return {Promise} a promise that resolves to the content. 104 | */ 105 | sync: function(callback){ 106 | 107 | var client = contentful.createClient(options.apiconfig); 108 | 109 | var db = { 110 | contentTypes: [], 111 | entries: {}, 112 | space: {} 113 | }; 114 | 115 | var skips = {}; 116 | 117 | var getEntries = function(locale, skip){ 118 | return client.entries({ locale:locale.code, limit:1000, skip:skip, order:'sys.createdAt' }); 119 | }; 120 | 121 | var fetchAll = function(locale, acc){ 122 | return function(result){ 123 | if (result.length == 1000){ 124 | skips[locale.code] += 1000; 125 | return getEntries(locale, skips[locale.code]).then(fetchAll(locale, acc.concat(result))); 126 | } else { 127 | db.entries[locale.code] = acc.concat(result); 128 | return acc.concat(result); 129 | } 130 | } 131 | }; 132 | 133 | var promise = q.all([ 134 | client.contentTypes(), 135 | client.space() 136 | ]).then( function(response) { 137 | var contentTypes = response[0]; 138 | var space = response[1]; 139 | 140 | db.contentTypes = contentTypes; 141 | db.space = space; 142 | 143 | return q.all(db.space.locales.map(function(locale) { 144 | skips[locale.code] = 0; 145 | return getEntries(locale, skips[locale.code]).then( fetchAll( locale, [] ) ); 146 | })).then(function(result) { 147 | return db; 148 | }); 149 | }).catch(function(error){ 150 | console.log(error); 151 | }); 152 | 153 | if (callback) { 154 | promise.then(function(db) { 155 | process.nextTick(function() { 156 | callback(undefined, db); 157 | }); 158 | }, callback); 159 | } 160 | return promise; 161 | }, 162 | 163 | /** 164 | * Renders HTML snippets for all entries. 165 | * 166 | * @param {Object} content The content. 167 | * @param {Function} callback (optional) a callback function(err, html) 168 | * @return {Promise} that resolves to an object with id of entry as key and HTML as value. 169 | */ 170 | render: function(content, before, callback) { 171 | // manually setup nunjucks to not cache templates since consolidate doesn't support this option 172 | 173 | // expose consolidate to allow for a custom setup 174 | console.log('[contentfulStatic.render] Calling "before" callback...'); 175 | if (before != undefined) before(consolidate, content); 176 | 177 | // Massage the data for some easy lookup 178 | var contentTypes = {}; 179 | content.contentTypes.reduce(function(types, ct) { 180 | checkExistance(ct, 'index.js:164'); 181 | types[ct.sys.id] = ct; 182 | return types; 183 | }, contentTypes); 184 | 185 | // FIXME: Only specified locale. 186 | var renderPromise = q.all(content.space.locales.map(function(locale) { 187 | console.log('[contentfulStatic.render] Traversing entries in locale ' + locale.code + ' ...'); 188 | 189 | // Massage the data for some easy lookup 190 | var entries = {}; 191 | content.entries[locale.code].reduce(function(entries, entry) { 192 | checkExistance(entry, 'index.js:177'); 193 | entries[entry.sys.id] = entry; 194 | return entries; 195 | }, entries); 196 | 197 | // Find out order to render in. 198 | var recurse = function(obj, list, contentTypes, dupCheck) { 199 | dupCheck = dupCheck || {}; 200 | 201 | // Render children first 202 | if (Array.isArray(obj)) { 203 | obj.forEach(function(item) { 204 | recurse(item, list, contentTypes, dupCheck); 205 | }); 206 | } else if (obj && typeof obj === 'object') { 207 | Object.keys(obj).forEach(function(k) { 208 | if (k !== '_sys' && k !== 'sys') { 209 | recurse(obj[k], list, contentTypes, dupCheck); 210 | } 211 | }); 212 | } 213 | 214 | // Then render current entry, if its an entry 215 | // It's an entry to us if it has a sys.contentType 216 | if (obj && obj.sys && obj.sys.contentType && obj.sys.id) { 217 | if (!dupCheck[obj.sys.id]) { 218 | list.push({ 219 | id: obj.sys.id, 220 | filename: obj.fields && obj.fields.id || obj.sys.id, 221 | name: obj.fields && obj.fields.name || obj.sys.id, 222 | contentType: contentTypes[obj.sys.contentType.sys.id].name, 223 | entry: obj 224 | }); 225 | dupCheck[obj.sys.id] = true; 226 | } 227 | } 228 | }; 229 | var toRender = []; 230 | recurse(entries, toRender, contentTypes); 231 | var debugTemplate = function(e) { 232 | return '

No template found

' + JSON.stringify(e, undefined, 2) + '
'; 233 | }; 234 | 235 | 236 | // Awesome! Let's render them, one at a time and include the rendered html in the context 237 | // of each so that they can in turn include it themselves. 238 | var render = function(entryObj, includes) { 239 | var deferred = q.defer(); 240 | 241 | // Try figuring out which template to use 242 | var exists = function(pth) { 243 | try { 244 | fs.accessSync(pth); 245 | return true; 246 | } catch (e) { 247 | return false; 248 | } 249 | }; 250 | 251 | // DEBUG log 252 | if(entryObj === undefined || typeof entryObj === "string") throw new Error('invalid entryObj at index.js:232'); 253 | var debugName = entryObj && entryObj.entry.fields && entryObj.entry.fields.name ? entryObj.entry.fields.name : entryObj.entry.sys.id; 254 | debug('rendering entry...', debugName); 255 | 256 | // Try a nested path 257 | var tmp = entryObj.contentType.split('-'); 258 | tmp[tmp.length - 1] = tmp[tmp.length - 1] + '.html'; 259 | var tmpl = path.join.apply(path, tmp); 260 | if (!exists(path.join(options.templates,tmpl))) { 261 | tmpl = entryObj.contentType + '.html'; 262 | } 263 | 264 | // Ok let's check again (TODO: DRY) 265 | if (!exists(path.join(options.templates, tmpl))) { 266 | debug('Could not find template ', path.join(options.templates,tmpl)); 267 | deferred.resolve('(Missing template)'); 268 | } else { 269 | var defaultContext = { 270 | entry: entryObj.entry, 271 | content: content, 272 | entries: entries, 273 | includes: includes, 274 | contentTypes: contentTypes, 275 | globals: { 276 | locale: locale.code 277 | }, 278 | debug: function(obj) { 279 | return JSON.stringify(obj, undefined, 2); 280 | }, 281 | include: function(obj) { 282 | if(obj == undefined) { 283 | debug('error: undefined object'); 284 | return false; 285 | } 286 | checkExistance(obj.sys, 'index.js:262'); 287 | if (Array.isArray(obj)) { 288 | return obj.map(function(e) { 289 | if (e && e.sys) { 290 | return includes[e.sys.id] || debugTemplate(e); 291 | } 292 | return debugTemplate(e); 293 | }).join('\n'); 294 | } else if (obj.sys) { 295 | return includes[obj.sys.id] || debugTemplate(obj); 296 | } 297 | } 298 | }; 299 | 300 | consolidate[options.engine]( 301 | path.join(options.templates, tmpl), merge.recursive(defaultContext, options.context), 302 | function(err, html) { 303 | if (err) { 304 | deferred.reject(err); 305 | } else { 306 | deferred.resolve(html); 307 | } 308 | } 309 | ); 310 | } 311 | return deferred.promise; 312 | }; 313 | 314 | var includes = {}; 315 | console.log('[contentfulStatic.render]', 'Rendering templates ...'); 316 | var promise = toRender.reduce(function(soFar, e) { 317 | checkExistance(e, 'index.js:294'); 318 | return soFar.then(function(includes) { 319 | debuginfo.renderCount++; 320 | return render(e, includes).then(function(html) { 321 | includes[e.id] = html; 322 | return includes; 323 | }); 324 | }); 325 | }, q(includes)); 326 | 327 | return promise; 328 | })).then(function(results) { 329 | // Re-map the data to each locale 330 | var byLocale = {}; 331 | console.log('[contentfulStatic.render] Rendered ' + debuginfo.renderCount + ' templates.'); 332 | console.log('[contentfulStatic.render] Remap data to locales.'); 333 | content.space.locales.forEach(function(l, index) { 334 | byLocale[l.code] = results[index]; 335 | }); 336 | return byLocale; 337 | }); 338 | 339 | if (callback) { 340 | renderPromise.then(function(includes) { 341 | process.nextTick(function() { 342 | callback(undefined, includes); 343 | }); 344 | }, callback); 345 | } 346 | return renderPromise; 347 | } 348 | }; 349 | 350 | return contentfulStatic; 351 | 352 | })(); 353 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contentful-static", 3 | "version": "1.4.2", 4 | "description": "Save data from a Contentful space to a local JSON file", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "grunt --gruntfile test/Gruntfile.js" 8 | }, 9 | "author": "Erik Edhagen ", 10 | "license": "ISC", 11 | "bin": "./bin/contentful-static.js", 12 | "dependencies": { 13 | "chalk": "^1.1.1", 14 | "consolidate": "^0.13.1", 15 | "contentful": "1.2.1", 16 | "merge": "^1.2.0", 17 | "mkdirp": "^0.5.1", 18 | "nunjucks": "^2.0.0", 19 | "q": "^1.4.1", 20 | "rimraf": "^2.4.3", 21 | "yargs": "^3.25.0" 22 | } 23 | } 24 | --------------------------------------------------------------------------------