├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── contributing.md ├── lib └── index.js ├── package.json ├── test ├── fixtures │ ├── data │ │ ├── app.js │ │ └── index.html │ ├── graphql │ │ ├── app.js │ │ └── index.html │ ├── template │ │ ├── app.js │ │ ├── index.html │ │ └── template.html │ ├── testFile.json │ ├── testFileTransformRaw.json │ └── tpl_resolve │ │ ├── app.js │ │ ├── layout.html │ │ └── templates │ │ └── tpl.html └── index.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .nyc_output 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | Makefile 4 | contributing.md 5 | .editorconfig 6 | .travis.yml 7 | .babelrc 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 6 5 | after_script: 6 | - npm run coveralls 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | License (MIT) 2 | ------------- 3 | 4 | Copyright (c) 2015 Jeff Escalante, Carrot Creative 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spike Records 2 | 3 | [](https://badge.fury.io/js/spike-records) [](https://travis-ci.org/static-dev/spike-records) [](https://david-dm.org/static-dev/spike-records) [](https://coveralls.io/github/static-dev/spike-records?branch=master) 4 | 5 | remote data -> static templates 6 | 7 | ## Why should you care? 8 | 9 | Static is the best, but sometimes you need to fetch data from a remote source which makes things not so static. Spike Records is a little webpack plugin intended for use with [spike](https://github.com/static-dev/spike) which allows you to make data pulled from a file or url available in your view templates. 10 | 11 | It can pull data from the following places: 12 | 13 | - A javascript object 14 | - A file containing a javascript object or JSON 15 | - A URL that returns JSON 16 | - A [GraphQL](http://graphql.org) endpoint 17 | 18 | ## Installation 19 | 20 | Install into your project with `npm i spike-records -S`. 21 | 22 | Then load it up as a plugin in `app.js` like this: 23 | 24 | ```javascript 25 | const Records = require('spike-records') 26 | const standard = require('reshape-standard') 27 | const locals = {} 28 | 29 | module.exports = { 30 | reshape: standard({ locals: () => locals }), 31 | plugins: [new Records({ 32 | addDataTo: locals, 33 | test: { file: 'data.json' } 34 | })] 35 | } 36 | ``` 37 | 38 | ## Usage 39 | 40 | The primary use case for spike-records is to inject local variables into your html templates, although technically it can be used for anything. In the example above, we use the [reshape-standard](https://github.com/reshape/standard) plugin pack to add variables (among other functionality) to your html. Spike's default template also uses `reshape-standard`. 41 | 42 | In order to use the results from spike-records, you must pass it an object, which it will put the resolved data on, using the `addDataTo` key. This plugin runs early in spike's compile process, so by the time templates are being compiled, the object will have all the data necessary on it. If you are using the data with other plugins, ensure that spike-records is the first plugin in the array. 43 | 44 | I know this is an unusual pattern for a javascript library, but the way it works is quite effective in this particular system, and affords a lot of flexibility and power. 45 | 46 | The records plugin accepts an object, and each key in the object (other than `addDataTo`) should contain another object as it's value, with either a `file`, `url`, `data`, `graphql` or `callback` property. For example: 47 | 48 | ```js 49 | const Records = require('spike-records') 50 | const locals = {} 51 | function myFunc () { 52 | // call any arbitrary API or computation to produce data 53 | return new Promise((resolve, reject) => {...}) 54 | } 55 | 56 | module.exports = { 57 | plugins: [new Records({ 58 | addDataTo: locals, 59 | one: { file: 'data.json' }, 60 | two: { url: 'http://api.carrotcreative.com/staff' }, 61 | three: { data: { foo: 'bar' } }, 62 | four: { 63 | graphql: { 64 | url: 'http://localhost:1234', 65 | query: 'query { allPosts { title } }', 66 | variables: 'xxx', // optional 67 | headers: { authorization: 'Bearer xxx' } // optional 68 | } 69 | }, 70 | five: { callback: myFunc } 71 | })] 72 | } 73 | ``` 74 | 75 | Whatever data source you provide, it will be resolved and added to your view templates as `[key]`. So for example, if you were trying to access `three` in your templates, you could access it with `three.foo`, and it would return `'bar'`, as such: 76 | 77 | ```jade 78 | p {{ three.foo }} 79 | ``` 80 | 81 | Now let's get into some more details for each of the data types. 82 | 83 | ### File 84 | 85 | `file` accepts a file path, either absolute or relative to your [spike](https://github.com/static-dev/spike) project's root. So for the example above, it would resolve to `/path/to/project/data.json`. 86 | 87 | ### Url 88 | 89 | `url` accepts either a string or an object. If provided with a string, it will make a request to the provided url and parse the result as JSON, then return it as a local. If you need to modify this behavior, you can pass in an object instead. The object is passed through directly to [rest.js](https://github.com/cujojs/rest), their docs for acceptable values for this object [can be found here](https://github.com/cujojs/rest/blob/master/docs/interfaces.md#common-request-properties). These options allow modification of the method, headers, params, request entity, etc. and should cover any additional needs. 90 | 91 | ### Data 92 | 93 | The most straightforward of the options, this will just pass the data right through to the locals. Also if you provide a A+ compliant promise for a value, it will be resolved and passed in to the template. 94 | 95 | ## Additional Options 96 | 97 | Alongside any of the data sources above, there are a few additional options you can provide in order to further manipulate the output. 98 | 99 | ### Transform 100 | 101 | If you want to transform the data from your source in any way before injecting it as a local, you can use this option. For example: 102 | 103 | ```js 104 | const Records = require('spike-records') 105 | const locals = {} 106 | 107 | module.exports = { 108 | plugins: [new Records({ 109 | addDataTo: locals, 110 | blog: { 111 | url: 'http://blog.com/api/posts', 112 | transform: (data) => { return data.response.posts } 113 | } 114 | })] 115 | } 116 | ``` 117 | 118 | ### Transform Raw 119 | 120 | If you want to transform the data from your source before it is processed by rest, for instance to remove cross site scripting protections, you can use this option. For example: 121 | 122 | ```js 123 | const Records = require('spike-records') 124 | const locals = {} 125 | 126 | module.exports = { 127 | plugins: [new Records({ 128 | addDataTo: locals, 129 | blog: { 130 | transformRaw: (data) => { return data.replace('])}while(1);', '') }, 131 | url: 'https://medium.com/glassboard-blog/?format=json' 132 | } 133 | })] 134 | } 135 | ``` 136 | 137 | ### Template 138 | 139 | Using the template option allows you to write objects returned from records to single page templates. For example, if you are trying to render a blog as static, you might want each `post` returned from the API to be rendered as a single page by itself. 140 | 141 | The `template` option is an object with `path` and `output` keys. `path` is an absolute or relative path to a template to be used to render each item, and `output` is a function with the currently iterated item as a parameter, which should return a string representing a path relative to the project root where the single view should be rendered. For example: 142 | 143 | ```js 144 | const Records = require('spike-records') 145 | const locals = {} 146 | 147 | module.exports = { 148 | plugins: [new Records({ 149 | addDataTo: locals, 150 | blog: { 151 | url: 'http://blog.com/api/posts', 152 | template: { 153 | path: 'templates/single.sgr', 154 | output: (post) => { return `posts/${post.slug}.html` } 155 | } 156 | } 157 | })] 158 | } 159 | ``` 160 | 161 | Note that for this feature to work correctly, the data returned from your data source must be an array. If it's not, the plugin will throw an error. If you need to transform the data before it is rendered into templates, you can do so using a `transform` function, as such: 162 | 163 | ```js 164 | const Records = require('spike-records') 165 | const locals = {} 166 | 167 | module.exports = { 168 | plugins: [new Records({ 169 | addDataTo: locals, 170 | blog: { 171 | url: 'http://blog.com/api/posts', 172 | template: { 173 | transform: (data) => { return data.response.posts }, 174 | path: 'templates/single.sml', 175 | output: (post) => { return `posts/${post.slug}.html` } 176 | } 177 | } 178 | })] 179 | } 180 | ``` 181 | 182 | If you use a `transform` function outside of the `template` block, this will still work. The difference is that a `transform` inside the `template` block will only use the transformed data for rendering single templates, whereas the normal `transform` option will alter that data that is injected into your view templates as locals, as well as the single templates. 183 | 184 | Inside your template, a local called `item` will be injected, which contains the contents of the item for which the template has been rendered. It will also contain all the other locals injected by spike-records and otherwise, fully transformed by any `transform` functions provided. 185 | 186 | ## License & Contributing 187 | 188 | - Details on the license [can be found here](LICENSE.md) 189 | - Details on running tests and contributing [can be found here](contributing.md) 190 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to spike-records 2 | 3 | Hello there! First of all, thanks for being interested in spike-records and helping out. We all think you are awesome, and by contributing to open source projects, you are making the world a better place. That being said, there are a few ways to make the process of contributing code to spike-records smoother, detailed below: 4 | 5 | ### Filing Issues 6 | 7 | If you are opening an issue about a bug, make sure that you include clear steps for how we can reproduce the problem. _If we can't reproduce it, we can't fix it_. If you are suggesting a feature, make sure your explanation is clear and detailed. 8 | 9 | ### Getting Set Up 10 | 11 | - Clone the project down 12 | - Make sure [nodejs](http://nodejs.org) has been installed and is above version `6.0.0` 13 | - Run `npm install` 14 | - Put in work 15 | 16 | ### Testing 17 | 18 | You can run the tests with `npm test`. They are written using [ava](https://github.com/sindresorhus/ava). 19 | 20 | ### Code Style 21 | 22 | This project uses ES6 directly through node >= v6. To keep a consistent coding style in the project, we are using [standard js](http://standardjs.com/). In order for tests to pass, all code must pass standard js linting. This project also uses an [editorconfig](http://editorconfig.org/). It will make life much easier if you have the [editorconfig plugin](http://editorconfig.org/#download) for your text editor. For any inline documentation in the code, we're using [JSDoc](http://usejsdoc.org/). 23 | 24 | ### Commit Cleanliness 25 | 26 | It's ok if you start out with a bunch of experimentation and your commit log isn't totally clean, but before any pull requests are accepted, we like to have a nice clean commit log. That means [well-written and clear commit messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) and commits that each do something significant, rather than being typo or bug fixes. 27 | 28 | If you submit a pull request that doesn't have a clean commit log, we will ask you to clean it up before we accept. This means being familiar with rebasing - if you are not, [this guide](https://help.github.com/articles/interactive-rebase) by github should help you to get started. And if you are still confused, feel free to ask! 29 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const W = require('when') 2 | const keys = require('when/keys') 3 | const node = require('when/node') 4 | const rest = require('rest') 5 | const fs = require('fs') 6 | const path = require('path') 7 | const reshape = require('reshape') 8 | const loader = require('reshape-loader') 9 | const SpikeUtil = require('spike-util') 10 | const bindAll = require('es6bindall') 11 | 12 | // A spike plugin is a webpack plugin. This source will be much easier to 13 | // navigate if you have an understanding of how webpack plugins work! 14 | // https://webpack.js.org/development/how-to-write-a-plugin/ 15 | module.exports = class Records { 16 | constructor (opts) { 17 | this.opts = opts 18 | // We need to bind the apply method so that it has access to `this.opts` as 19 | // set above. Otherwise, `this` is set to webpack's compiler instance. 20 | bindAll(this, ['apply']) 21 | } 22 | 23 | apply (compiler) { 24 | this.util = new SpikeUtil(compiler.options) 25 | 26 | // As soon as we can, we want to resolve the data from its sources. The 27 | // run hook is a perfect place to do this, it's early and async-compatible. 28 | this.util.runAll(compiler, run.bind(this, compiler)) 29 | 30 | // When rendering templates, reshape setting can use the loader context to 31 | // determine, for example, the currently processed file's name. As such, we 32 | // need a copy of the loader context for when we render them. 33 | compiler.plugin('compilation', (compilation) => { 34 | compilation.plugin('normal-module-loader', (loaderContext) => { 35 | this.loaderContext = loaderContext 36 | }) 37 | }) 38 | 39 | // As the other templates are being written, we write out single page 40 | // templates as well. 41 | compiler.plugin('emit', (compilation, done) => { 42 | keys.map(this._locals, writeTemplates.bind(this, compilation, compiler)) 43 | .done(() => { done() }, done) 44 | }) 45 | } 46 | } 47 | 48 | /** 49 | * The main "run" function is responsible for resolving the data and placing it 50 | * on the addDataTo object. It also has some setup steps for template rendering. 51 | * @param {Compiler} compiler - webpack compiler instance 52 | * @param {Compilation} compilation - webpack compilation instance 53 | * @param {Function} done - callback for when we're finished 54 | */ 55 | function run (compiler, compilation, done) { 56 | const tasks = {} 57 | const spikeOpts = this.util.getSpikeOptions() 58 | 59 | // First, we go through each of the keys in the plugin's options and resolve 60 | // the data as necessary. Data from files or urls will return promises. We 61 | // place all resolved data and promised into a "tasks" object with their 62 | // appropriate keys. Promises still need to be resolved at this point. 63 | for (const k in this.opts) { 64 | if (this.opts[k].callback) { tasks[k] = this.opts[k].callback() } 65 | if (this.opts[k].data) { tasks[k] = renderData(this.opts[k]) } 66 | if (this.opts[k].url) { tasks[k] = renderUrl(this.opts[k]) } 67 | if (this.opts[k].graphql) { tasks[k] = renderGraphql(this.opts[k]) } 68 | if (this.opts[k].file) { 69 | tasks[k] = renderFile(compiler.options.context, this.opts[k]) 70 | } 71 | 72 | // Here, we check to see if the user has provided a single-view template, 73 | // and if so, add its path to spike's ignores. This is because templates 74 | // render separately with special variables through this plugin and 75 | // shouldn't be processed as normal views by spike. 76 | const tpl = this.opts[k].template 77 | if (tpl && Object.keys(tpl).length) { 78 | spikeOpts.ignore.push(path.join(compiler.options.context, tpl.path)) 79 | } 80 | } 81 | 82 | // Here's where the magic happens. First we use the when/keys utility to go 83 | // through our "tasks" object and resolve all the promises. More info here: 84 | // https://github.com/cujojs/when/blob/master/docs/api.md#whenkeys-all 85 | keys.all(tasks) 86 | // Then we go through each of they keys again, applying the user-provided 87 | // transform function to each one, if it exists. 88 | .then((tasks) => keys.map(tasks, transformData.bind(this))) 89 | // After this, we add the fully resolved and transformed data to the 90 | // addDataTo object, so it can be made available in views. 91 | .tap(mergeIntoLocals.bind(this)) 92 | // Then we save the locals on a class property for templates to use. We will 93 | // need this in a later webpack hook when we're writing templates. 94 | .then((locals) => { this._locals = locals }) 95 | // And finally, tell webpack we're done with our business here 96 | .done(() => { done() }, done) 97 | } 98 | 99 | // Below are the methods we use to resolve data from each type. Very 100 | // straightforward, really. 101 | function renderData (obj) { 102 | return obj.data 103 | } 104 | 105 | function renderFile (root, obj) { 106 | return node.call(fs.readFile.bind(fs), path.join(root, obj.file), 'utf8') 107 | .then((content) => { return JSON.parse(transformRaw(obj, content)) }) 108 | } 109 | 110 | function renderUrl (obj) { 111 | return rest(obj.url).then((res) => { return JSON.parse(transformRaw(obj, res.entity)) }) 112 | } 113 | 114 | function renderGraphql (obj) { 115 | const headers = Object.assign({ 'Content-Type': 'application/json' }, obj.graphql.headers) 116 | 117 | return rest({ 118 | path: obj.graphql.url, 119 | entity: JSON.stringify({ 120 | query: obj.graphql.query, 121 | variables: obj.graphql.variables 122 | }), 123 | headers 124 | }).then((res) => { return JSON.parse(transformRaw(obj, res.entity)) }) 125 | } 126 | 127 | /** 128 | * If the user provided a transform function for a given data source, run the 129 | * function and return the transformed data. Otherwise, return the data as it 130 | * exists inititally. 131 | * @param {Object} data - data as resolved from user-provided data source 132 | * @param {String} k - key associated with the data 133 | * @return {Object} Modified or original data 134 | */ 135 | function transformData (data, k) { 136 | return this.opts[k].transform ? this.opts[k].transform(data) : data 137 | } 138 | 139 | /** 140 | * If the user provided a transformRaw function for a given data source, run the 141 | * function and return the transformed data. Otherwise, return the data as it 142 | * exists inititally. 143 | * @param {Object} obj - this.opts[k] object 144 | * @param {Object} data - data as resolved from user-provided data source 145 | * @return {Object} Modified or original data 146 | */ 147 | function transformRaw (obj, data) { 148 | return obj.transformRaw ? obj.transformRaw(data) : data 149 | } 150 | 151 | /** 152 | * Given an object of resolved data, add it to the `addDataTo` object. 153 | * @param {Object} data - data resolved by the plugin 154 | */ 155 | function mergeIntoLocals (data) { 156 | this.opts.addDataTo = Object.assign(this.opts.addDataTo, data) 157 | } 158 | 159 | /** 160 | * Single page templates are a complicated business. Since they need to be 161 | * parsed with a custom set of locals, they cannot be rendered purely through 162 | * webpack's pipeline, unless we required a function wrapper for the locals 163 | * object like spike-collections does. As such, we render them manually, but in 164 | * a way that exactly replicates the way they are rendered through the reshape 165 | * loader webpack uses internally. 166 | * 167 | * When called in the webpack emit hook above, it per key -- that is, if the 168 | * user has specified: 169 | * 170 | * { test: { url: 'http://example.com' }, test2: { file: './foo.json' } } 171 | * 172 | * This method will get the 'test' and 'test2' keys along with their resolved 173 | * data from the data sources specified. 174 | * 175 | * @param {Compilation} compilation - webpack compilation instance 176 | * @param {Compiler} compiler - webpack compiler instance 177 | * @param {Object} _data - resolved data for the user-given key 178 | * @param {String} k - key name for the data 179 | * @return {Promise} promise for written templates 180 | */ 181 | function writeTemplates (compilation, compiler, _data, k) { 182 | const tpl = this.opts[k].template 183 | const root = compiler.options.context 184 | 185 | // If the template option doesn't exist or is malformed, we return or error. 186 | if (!tpl) { return _data } 187 | if (!tpl.path) { throw new Error('missing template.path') } 188 | if (!tpl.output) { throw new Error('missing template.output') } 189 | 190 | // If there is also a template transform function, we run that here 191 | const data = tpl.transform ? tpl.transform(_data) : _data 192 | // We must ensure that template data is an array to render each item 193 | if (!Array.isArray(data)) { throw new Error('template data is not an array') } 194 | 195 | // First we read the template file 196 | const tplPath = path.join(root, tpl.path) 197 | return node.call(fs.readFile.bind(fs), tplPath, 'utf8') 198 | .then((template) => { 199 | // Now we go through each item in the data array to render a template 200 | return W.map(data, (item) => { 201 | // The template gets all the default locals as well as an "item" prop 202 | // that contains the data specific to the template, and a filename 203 | const newLocals = Object.assign({}, this.opts.addDataTo, { 204 | item, 205 | filename: path.join(root, tpl.path) 206 | }) 207 | 208 | // We need to precisely replicate the way reshape is set up internally 209 | // in order to render the template correctly, so we run the reshape 210 | // loader's options parsing with the real loader context and the user's 211 | // reshape options from the config 212 | const options = loader.parseOptions.call(this.loaderContext, this.util.getSpikeOptions().reshape, {}) 213 | 214 | // And finally, we run reshape to generate the template! 215 | return reshape(Object.assign(options, { 216 | locals: newLocals, 217 | filename: tplPath 218 | })) 219 | .process(template) 220 | .then((res) => { 221 | const rendered = res.output(newLocals) 222 | // And then add the generated template to webpack's output assets 223 | compilation.assets[tpl.output(item)] = { 224 | source: () => rendered, 225 | size: () => rendered.length 226 | } 227 | }) 228 | }) 229 | }) 230 | } 231 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spike-records", 3 | "description": "compile remote data into your templates", 4 | "version": "2.2.1", 5 | "author": "Jeff Escalante", 6 | "ava": { 7 | "verbose": "true", 8 | "serial": "true" 9 | }, 10 | "bugs": "https://github.com/static-dev/spike-records/issues", 11 | "dependencies": { 12 | "es6bindall": "^0.0.9", 13 | "reshape": "^1.0.0", 14 | "reshape-loader": "^1.3.0", 15 | "rest": "^2.0.0", 16 | "spike-util": "^1.3.0", 17 | "when": "^3.7.7" 18 | }, 19 | "devDependencies": { 20 | "ava": "^0.25.0", 21 | "coveralls": "^3.0.2", 22 | "nyc": "^12.0.2", 23 | "posthtml-exp": "^0.9.0", 24 | "reshape-standard": "^3.3.0", 25 | "rimraf": "^2.6.2", 26 | "spike-core": "^2.3.0", 27 | "standard": "^11.0.1" 28 | }, 29 | "engines": { 30 | "node": ">= 6.0.0" 31 | }, 32 | "homepage": "https://github.com/static-dev/spike-records", 33 | "keywords": [ 34 | "spikeplugin" 35 | ], 36 | "license": "MIT", 37 | "main": "lib", 38 | "repository": "static-dev/spike-records", 39 | "scripts": { 40 | "coverage": "nyc ava", 41 | "coveralls": "nyc --reporter=lcov ava && cat ./coverage/lcov.info | coveralls", 42 | "test": "ava" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/fixtures/data/app.js: -------------------------------------------------------------------------------- 1 | console.log('test') 2 | -------------------------------------------------------------------------------- /test/fixtures/data/index.html: -------------------------------------------------------------------------------- 1 |
{{ test.success }}
2 | -------------------------------------------------------------------------------- /test/fixtures/graphql/app.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-records/465957881448b5cf0d0cf3233cadb46b7ae6d935/test/fixtures/graphql/app.js -------------------------------------------------------------------------------- /test/fixtures/graphql/index.html: -------------------------------------------------------------------------------- 1 |{{ test.data.allPosts[0].description }}
2 | -------------------------------------------------------------------------------- /test/fixtures/template/app.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-records/465957881448b5cf0d0cf3233cadb46b7ae6d935/test/fixtures/template/app.js -------------------------------------------------------------------------------- /test/fixtures/template/index.html: -------------------------------------------------------------------------------- 1 |{{ posts.length }}
2 | -------------------------------------------------------------------------------- /test/fixtures/template/template.html: -------------------------------------------------------------------------------- 1 |{{ item.title }}
2 | -------------------------------------------------------------------------------- /test/fixtures/testFile.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/testFileTransformRaw.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": false 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/tpl_resolve/app.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/static-dev/spike-records/465957881448b5cf0d0cf3233cadb46b7ae6d935/test/fixtures/tpl_resolve/app.js -------------------------------------------------------------------------------- /test/fixtures/tpl_resolve/layout.html: -------------------------------------------------------------------------------- 1 |layout
2 |template
4 |true
') 27 | } 28 | }) 29 | }) 30 | 31 | test('loads data correctly', t => { 32 | const locals = {} 33 | return compileAndCheck({ 34 | fixture: 'data', 35 | locals, 36 | config: { addDataTo: locals, test: { data: { success: 'true' } } }, 37 | verify: (_, publicPath) => { 38 | const out = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 39 | t.is(out.trim(), 'true
') 40 | } 41 | }) 42 | }) 43 | 44 | test('loads a file correctly', t => { 45 | const locals = {} 46 | return compileAndCheck({ 47 | fixture: 'data', 48 | locals: locals, 49 | config: { addDataTo: locals, test: { file: '../testFile.json' } }, 50 | verify: (_, publicPath) => { 51 | const out = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 52 | t.is(out.trim(), 'true
') 53 | } 54 | }) 55 | }) 56 | 57 | test('loads a url correctly', t => { 58 | const locals = {} 59 | return compileAndCheck({ 60 | fixture: 'data', 61 | locals: locals, 62 | config: { 63 | addDataTo: locals, 64 | test: { url: 'http://api.bycarrot.com/v3/staff' } 65 | }, 66 | verify: (_, publicPath) => { 67 | const out = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 68 | t.is(out.trim(), 'true
') 69 | } 70 | }) 71 | }) 72 | 73 | test('loads a graphql endpoint correctly', t => { 74 | const locals = {} 75 | return compileAndCheck({ 76 | fixture: 'graphql', 77 | locals: locals, 78 | config: { 79 | addDataTo: locals, 80 | test: { 81 | graphql: { 82 | url: 'https://api.graph.cool/simple/v1/cizz44m7pmezz016487cxed19', 83 | query: '{ allPosts { description } }' 84 | } 85 | } 86 | }, 87 | verify: (_, publicPath) => { 88 | const out = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 89 | t.regex(out, /test/) 90 | } 91 | }) 92 | }) 93 | 94 | test('transform option works', t => { 95 | const locals = {} 96 | return compileAndCheck({ 97 | fixture: 'data', 98 | locals: locals, 99 | config: { 100 | addDataTo: locals, 101 | test: { 102 | data: { success: true }, 103 | transform: data => { 104 | return { success: false } 105 | } 106 | } 107 | }, 108 | verify: (_, publicPath) => { 109 | const out = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 110 | t.is(out.trim(), 'false
') 111 | } 112 | }) 113 | }) 114 | 115 | test('transformRaw option works', t => { 116 | const locals = {} 117 | return compileAndCheck({ 118 | fixture: 'data', 119 | locals: locals, 120 | config: { 121 | addDataTo: locals, 122 | test: { 123 | transformRaw: data => { 124 | return data.replace('])}while(1);', '') 125 | }, 126 | url: 'https://medium.com/glassboard-blog/?format=json' 127 | } 128 | }, 129 | verify: (_, publicPath) => { 130 | const out = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 131 | t.is(out.trim(), 'true
') 132 | } 133 | }) 134 | }) 135 | 136 | test.cb('single template errors with no "path" param', t => { 137 | const locals = {} 138 | const { project } = configProject('template', { 139 | addDataTo: locals, 140 | posts: { 141 | data: [{ title: 'wow' }, { title: 'amaze' }], 142 | template: {} 143 | }, 144 | locals 145 | }) 146 | 147 | project.on('warning', t.end) 148 | project.on('compile', t.end) 149 | project.on('error', err => { 150 | t.is(err.toString(), 'Error: missing template.path') 151 | t.end() 152 | }) 153 | 154 | project.compile() 155 | }) 156 | 157 | test.cb('single template errors with no "output" param', t => { 158 | const locals = {} 159 | const { project } = configProject('template', { 160 | addDataTo: locals, 161 | posts: { 162 | data: [{ title: 'wow' }, { title: 'amaze' }], 163 | template: { path: 'foo' } 164 | }, 165 | locals 166 | }) 167 | 168 | project.on('warning', t.end) 169 | project.on('compile', t.end) 170 | project.on('error', err => { 171 | t.is(err.toString(), 'Error: missing template.output') 172 | t.end() 173 | }) 174 | 175 | project.compile() 176 | }) 177 | 178 | test.cb('single template errors with non-array data', t => { 179 | const locals = {} 180 | const { project } = configProject('template', { 181 | addDataTo: locals, 182 | posts: { 183 | data: 'foo', 184 | template: { 185 | path: 'template.html', 186 | output: () => 'wow.html' 187 | } 188 | }, 189 | locals 190 | }) 191 | 192 | project.on('warning', t.end) 193 | project.on('compile', t.end) 194 | project.on('error', err => { 195 | t.is(err.toString(), 'Error: template data is not an array') 196 | t.end() 197 | }) 198 | 199 | project.compile() 200 | }) 201 | 202 | test('single template works with "path" and "template" params', t => { 203 | const locals = {} 204 | return compileAndCheck({ 205 | fixture: 'template', 206 | locals: locals, 207 | config: { 208 | addDataTo: locals, 209 | posts: { 210 | data: [{ title: 'wow' }, { title: 'amaze' }], 211 | template: { 212 | path: 'template.html', 213 | output: item => `posts/${item.title}.html` 214 | } 215 | } 216 | }, 217 | verify: (_, publicPath) => { 218 | const index = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 219 | const wow = fs.readFileSync( 220 | path.join(publicPath, 'posts/wow.html'), 221 | 'utf8' 222 | ) 223 | const amaze = fs.readFileSync( 224 | path.join(publicPath, 'posts/amaze.html'), 225 | 'utf8' 226 | ) 227 | t.is(index.trim(), '2
') 228 | t.is(wow.trim(), 'wow
') 229 | t.is(amaze.trim(), 'amaze
') 230 | } 231 | }) 232 | }) 233 | 234 | test('single template works with "transform" param', t => { 235 | const locals = {} 236 | return compileAndCheck({ 237 | fixture: 'template', 238 | locals: locals, 239 | config: { 240 | addDataTo: locals, 241 | posts: { 242 | data: { response: [{ title: 'wow' }, { title: 'amaze' }] }, 243 | template: { 244 | transform: data => data.response, 245 | path: 'template.html', 246 | output: item => `posts/${item.title}.html` 247 | } 248 | } 249 | }, 250 | verify: (_, publicPath) => { 251 | const index = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8') 252 | const wow = fs.readFileSync( 253 | path.join(publicPath, 'posts/wow.html'), 254 | 'utf8' 255 | ) 256 | const amaze = fs.readFileSync( 257 | path.join(publicPath, 'posts/amaze.html'), 258 | 'utf8' 259 | ) 260 | t.is(index.trim(), 'undefined
') // bc the transform is not global 261 | t.is(wow.trim(), 'wow
') 262 | t.is(amaze.trim(), 'amaze
') 263 | } 264 | }) 265 | }) 266 | 267 | test('template resolves include/layouts from its own path', t => { 268 | const locals = {} 269 | return compileAndCheck({ 270 | fixture: 'tpl_resolve', 271 | locals: locals, 272 | config: { 273 | addDataTo: locals, 274 | posts: { 275 | data: { response: [{ title: 'wow' }] }, 276 | template: { 277 | transform: data => data.response, 278 | path: 'templates/tpl.html', 279 | output: item => `posts/${item.title}.html` 280 | } 281 | } 282 | }, 283 | verify: (_, publicPath) => { 284 | const wow = fs.readFileSync( 285 | path.join(publicPath, 'posts/wow.html'), 286 | 'utf8' 287 | ) 288 | t.is(wow.trim(), 'layout
\ntemplate
') 289 | } 290 | }) 291 | }) 292 | 293 | // 294 | // Utilities 295 | // 296 | 297 | /** 298 | * Given a fixture, records config, and locals, set up a spike project instance 299 | * and return the instance and project path for compilation. 300 | * @param {String} fixturePath - path to the text fixture project 301 | * @param {Object} recordsConfig - config to be passed to record plugin 302 | * @param {Object} locals - locals to be passed to views 303 | * @return {Object} projectPath (str) and project (Spike instance) 304 | */ 305 | function configProject(fixturePath, recordsConfig, locals) { 306 | const projectPath = path.join(fixturesPath, fixturePath) 307 | const project = new Spike({ 308 | root: projectPath, 309 | entry: { main: path.join(projectPath, 'app.js') }, 310 | reshape: htmlStandards({ 311 | locals: () => { 312 | return locals 313 | } 314 | }), 315 | ignore: ['template.html'], 316 | plugins: [new Records(recordsConfig)] 317 | }) 318 | return { projectPath, project } 319 | } 320 | 321 | /** 322 | * Given a spike project instance, compile it, and return a promise for the 323 | * results. 324 | * @param {Spike} project - spike project instance 325 | * @return {Promise} promise for a compiled project 326 | */ 327 | function compileProject(project) { 328 | return new Promise((resolve, reject) => { 329 | project.on('error', reject) 330 | project.on('warning', reject) 331 | project.on('compile', resolve) 332 | project.compile() 333 | }) 334 | } 335 | 336 | /** 337 | * Compile a spike project and offer a callback hook to run your tests on the 338 | * results of the project. 339 | * @param {Object} opts - configuration object 340 | * @param {String} opts.fixture - name of the project folder inside /fixtures 341 | * @param {Object} opts.locals - object to be passed to view engine 342 | * @param {Object} opts.config - config object for records plugin 343 | * @param {Function} opts.verify - callback for when the project has compiled, 344 | * passes webpack compile result data and the project's public path 345 | * @return {Promise} promise for completed compiled project 346 | */ 347 | function compileAndCheck(opts) { 348 | const { projectPath, project } = configProject( 349 | opts.fixture, 350 | opts.config, 351 | opts.locals 352 | ) 353 | const publicPath = path.join(projectPath, 'public') 354 | return compileProject(project) 355 | .then(data => opts.verify(data, publicPath)) 356 | .then(() => rimrafPromise(publicPath)) 357 | } 358 | 359 | function rimrafPromise(dir) { 360 | return new Promise((resolve, reject) => { 361 | rimraf(dir, err => { 362 | if (err) return reject(err) 363 | resolve() 364 | }) 365 | }) 366 | } 367 | --------------------------------------------------------------------------------