Skip to content

Commit

Permalink
very thorough commenting of the source, small fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
jescalan committed Feb 16, 2017
1 parent 8749013 commit 099f917
Show file tree
Hide file tree
Showing 3 changed files with 364 additions and 277 deletions.
106 changes: 93 additions & 13 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,60 +9,93 @@ const loader = require('reshape-loader')
const SpikeUtil = require('spike-util')
const bindAll = require('es6bindall')

// this needs to be a function that returns a function
// A spike plugin is a webpack plugin. This source will be much easier to
// navigate if you have an understanding of how webpack plugins work!
// https://webpack.js.org/development/how-to-write-a-plugin/
module.exports = class Records {
constructor (opts) {
this.opts = opts
// We need to bind the apply method so that it has access to `this.opts` as
// set above. Otherwise, `this` is set to webpack's compiler instance.
bindAll(this, ['apply'])
}

apply (compiler) {
this.util = new SpikeUtil(compiler.options)

compiler.plugin('run', run.bind(this, compiler))
compiler.plugin('watch-run', run.bind(this, compiler))
// As soon as we can, we want to resolve the data from its sources. The
// run hook is a perfect place to do this, it's early and async-compatible.
this.util.runAll(compiler, run.bind(this, compiler))

// When rendering templates, reshape setting can use the loader context to
// determine, for example, the currently processed file's name. As such, we
// need a copy of the loader context for when we render them.
compiler.plugin('compilation', (compilation) => {
compilation.plugin('normal-module-loader', (loaderContext) => {
this.loaderContext = loaderContext
})
})

// As the other templates are being written, we write out single page
// templates as well.
compiler.plugin('emit', (compilation, done) => {
keys.map(this._locals, writeTemplates.bind(this, compilation, compiler))
.done(() => { done() }, done)
})
}
}

/**
* The main "run" function is responsible for resolving the data and placing it
* on the addDataTo object. It also has some setup steps for template rendering.
* @param {Compiler} compiler - webpack compiler instance
* @param {Compilation} compilation - webpack compilation instance
* @param {Function} done - callback for when we're finished
*/
function run (compiler, compilation, done) {
const tasks = {}
const templates = []
const spikeOpts = this.util.getSpikeOptions()

// First, we go through each of the keys in the plugin's options and resolve
// the data as necessary. Data from files or urls will return promises. We
// place all resolved data and promised into a "tasks" object with their
// appropriate keys. Promises still need to be resolved at this point.
for (const k in this.opts) {
if (this.opts[k].data) { tasks[k] = renderData(this.opts[k]) }
if (this.opts[k].url) { tasks[k] = renderUrl(this.opts[k]) }
if (this.opts[k].file) {
tasks[k] = renderFile(compiler.options.context, this.opts[k])
}
if (this.opts[k].template && Object.keys(this.opts[k].template).length) {
templates.push(this.opts[k].template.path)

// Here, we check to see if the user has provided a single-view template,
// and if so, add its path to spike's ignores. This is because templates
// render separately with special variables through this plugin and
// shouldn't be processed as normal views by spike.
const tpl = this.opts[k].template
if (tpl && Object.keys(tpl).length) {
spikeOpts.ignore.push(path.join(compiler.options.context, tpl.path))
}
}

// templates need to be ignored as they often contain extra variables
templates.map((t) => {
const ignorePath = path.join(compiler.options.context, t)
this.util.getSpikeOptions().ignore.push(ignorePath)
})

// Here's where the magic happens. First we use the when/keys utility to go
// through our "tasks" object and resolve all the promises. More info here:
// https://github.com/cujojs/when/blob/master/docs/api.md#whenkeys-all
keys.all(tasks)
// Then we go through each of they keys again, applying the user-provided
// transform function to each one, if it exists.
.then((tasks) => keys.map(tasks, transformData.bind(this)))
// After this, we add the fully resolved and transformed data to the
// addDataTo object, so it can be made available in views.
.tap(mergeIntoLocals.bind(this))
// Then we save the locals on a class property for templates to use. We will
// need this in a later webpack hook when we're writing templates.
.then((locals) => { this._locals = locals })
// And finally, tell webpack we're done with our business here
.done(() => { done() }, done)
}

// Below are the methods we use to resolve data from each type. Very
// straightforward, really.
function renderData (obj) {
return obj.data
}
Expand All @@ -76,40 +109,87 @@ function renderUrl (obj) {
return rest(obj.url).then((res) => { return JSON.parse(res.entity) })
}

/**
* If the user provided a transform function for a given data source, run the
* function and return the transformed data. Otherwise, return the data as it
* exists inititally.
* @param {Object} data - data as resolved from user-provided data source
* @param {String} k - key associated with the data
* @return {Object} Modified or original data
*/
function transformData (data, k) {
if (!this.opts[k].transform) { return data }
return this.opts[k].transform(data)
}

/**
* Given an object of resolved data, add it to the `addDataTo` object.
* @param {Object} data - data resolved by the plugin
*/
function mergeIntoLocals (data) {
this.opts.addDataTo = Object.assign(this.opts.addDataTo, data)
}

/**
* Single page templates are a complicated business. Since they need to be
* parsed with a custom set of locals, they cannot be rendered purely through
* webpack's pipeline, unless we required a function wrapper for the locals
* object like spike-collections does. As such, we render them manually, but in
* a way that exactly replicates the way they are rendered through the reshape
* loader webpack uses internally.
*
* When called in the webpack emit hook above, it per key -- that is, if the
* user has specified:
*
* { test: { url: 'http://example.com' }, test2: { file: './foo.json' } }
*
* This method will get the 'test' and 'test2' keys along with their resolved
* data from the data sources specified.
*
* @param {Compilation} compilation - webpack compilation instance
* @param {Compiler} compiler - webpack compiler instance
* @param {Object} _data - resolved data for the user-given key
* @param {String} k - key name for the data
* @return {Promise} promise for written templates
*/
function writeTemplates (compilation, compiler, _data, k) {
const tpl = this.opts[k].template
const root = compiler.options.context

// If the template option doesn't exist or is malformed, we return or error.
if (!tpl) { return _data }
if (!tpl.path) { throw new Error('missing template.path') }
if (!tpl.output) { throw new Error('missing template.output') }

// If there is also a template transform function, we run that here
const data = tpl.transform ? tpl.transform(_data) : _data
// We must ensure that template data is an array to render each item
if (!Array.isArray(data)) { throw new Error('template data is not an array') }

// First we read the template file
return node.call(fs.readFile.bind(fs), path.join(root, tpl.path), 'utf8')
.then((template) => {
// Now we go through each item in the data array to render a template
return W.map(data, (item) => {
// The template gets all the default locals as well as an "item" prop
// that contains the data specific to the template, and a filename
Object.assign(this.opts.addDataTo, {
item: item,
item,
filename: path.join(root, tpl.path)
})

// We need to prceisely replicate the way reshape is set up internally
// in order to render the template correctly, so we run the reshape
// loader's options parsing with the real loader context and the user's
// reshape options from the config
const options = loader.parseOptions.call(this.loaderContext, this.util.getSpikeOptions().reshape, {})

// And finally, we run reshape to generate the template!
return reshape(options)
.process(template)
.then(((locals, res) => {
const rendered = res.output(locals)
// And then add the generated template to webpack's output assets
compilation.assets[tpl.output(item)] = {
source: () => rendered,
size: () => rendered.length
Expand Down
29 changes: 27 additions & 2 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ test('loads data correctly', (t) => {
const locals = {}
return compileAndCheck({
fixture: 'data',
locals: locals,
locals,
config: { addDataTo: locals, test: { data: { result: 'true' } } },
verify: (_, publicPath, cb) => {
verify: (_, publicPath) => {
const out = fs.readFileSync(path.join(publicPath, 'index.html'), 'utf8')
t.is(out.trim(), '<p>true</p>')
}
Expand Down Expand Up @@ -186,6 +186,14 @@ test('single template works with "transform" param', (t) => {
// Utilities
//

/**
* Given a fixture, records config, and locals, set up a spike project instance
* and return the instance and project path for compilation.
* @param {String} fixturePath - path to the text fixture project
* @param {Object} recordsConfig - config to be passed to record plugin
* @param {Object} locals - locals to be passed to views
* @return {Object} projectPath (str) and project (Spike instance)
*/
function configProject (fixturePath, recordsConfig, locals) {
const projectPath = path.join(fixturesPath, fixturePath)
const project = new Spike({
Expand All @@ -199,6 +207,12 @@ function configProject (fixturePath, recordsConfig, locals) {
return { projectPath, project }
}

/**
* Given a spike project instance, compile it, and return a promise for the
* results.
* @param {Spike} project - spike project instance
* @return {Promise} promise for a compiled project
*/
function compileProject (project) {
return new Promise((resolve, reject) => {
project.on('error', reject)
Expand All @@ -208,6 +222,17 @@ function compileProject (project) {
})
}

/**
* Compile a spike project and offer a callback hook to run your tests on the
* results of the project.
* @param {Object} opts - configuration object
* @param {String} opts.fixture - name of the project folder inside /fixtures
* @param {Object} opts.locals - object to be passed to view engine
* @param {Object} opts.config - config object for records plugin
* @param {Function} opts.verify - callback for when the project has compiled,
* passes webpack compile result data and the project's public path
* @return {Promise} promise for completed compiled project
*/
function compileAndCheck (opts) {
const {projectPath, project} = configProject(opts.fixture, opts.config, opts.locals)
const publicPath = path.join(projectPath, 'public')
Expand Down
Loading

0 comments on commit 099f917

Please sign in to comment.