x
Version:
Apply HTML transformations using attributes
186 lines (164 loc) • 5.62 kB
JavaScript
'use strict'
const path = require('path')
const Denque = require('denque')
const Parser = require('parse5/lib/parser')
const vinylFile = require('vinyl-file')
const pify = require('pify')
const async = pify(require('async'))
const fs = pify(require('fs'))
const util = require('./lib/util')
// TODO add comments
module.exports = class X {
constructor (opts) {
this.opts = opts || {}
this.opts.format = opts.format || /^x-(.+)$/
this.opts.resolvers = opts.resolvers || ['.']
this.opts.cwd = opts.cwd || process.cwd()
this.opts.base = opts.base || this.opts.cwd
this.opts.directives = opts.directives || {}
this._resolvePathMemo = Object.create(null)
this._resolveFileMemo = Object.create(null)
this._parser = new Parser()
this._parser._bootstrap = (document, fragmentContext) => {
Parser.prototype._bootstrap.call(this._parser, document, fragmentContext)
this._parser.tokenizer._isDuplicateAttr = () => false
}
this.ast = Symbol()
this.file = Symbol()
this.dirtyNodes = Symbol()
this.pipeline = Symbol()
this.linkAttr = Symbol()
this.lastFile = Symbol()
}
resolvePath (filePath, extraPaths = []) {
let paths = this.opts.resolvers.concat(extraPaths)
let memoPath = JSON.stringify({filePath, paths})
if (memoPath in this._resolvePathMemo) {
return this._resolvePathMemo[memoPath]
}
let fullPaths = paths.map(r => path.join(this.opts.cwd, r, filePath))
return async.detect(fullPaths, (fullPath, next) => {
fs.access(fullPath, err => next(null, !err))
})
.then(result => {
if (result) {
return path.normalize(path.resolve(result))
}
throw new Error(`Could not resolve ${filePath}`)
})
}
resolveFile (filePath) {
if (filePath in this._resolveFileMemo) {
return this._resolveFileMemo[filePath]
}
return (this._resolveFileMemo[filePath] = vinylFile.read(filePath, {cwd: this.opts.cwd, base: this.opts.base}))
}
getDirective (attrName) {
return (this.opts.format.exec(attrName) || [])[1]
}
_initPipeline (nodes, runtime) {
nodes[this.dirtyNodes] = new Denque()
return async.each(util.walkNodes(nodes), (node, nextNode) => {
let pipeline
let ref
async.filterSeries(node.attrs, (attr, nextAttr) => {
let directive = this.getDirective(attr.name)
if (directive) {
if (directive in this.opts.directives) {
if (!pipeline) {
pipeline = node[this.pipeline] = new Denque()
nodes[this.dirtyNodes].push(node)
}
pipeline.push(this.opts.directives[directive](attr.value, runtime, this))
} else {
return nextNode(new Error(`Unknown directive: ${directive}`))
}
} else if ((attr.name === 'src' || attr.name === 'href') && !util.isExternal(attr.value)) {
node[this.linkAttr] = attr
ref = attr.value
}
nextAttr(null, !directive)
})
.then(attrs => {
node.attrs = attrs
if (pipeline && ref) {
let parent = []
if (!ref.startsWith('/')) {
parent = path.relative(this.opts.cwd, nodes[this.file].dirname)
}
this.resolvePath(ref, parent)
.then(fullPath => this.resolveFile(fullPath))
.then(file => (node[this.lastFile] = file))
.then(() => nextNode(), err => nextNode(err))
} else {
nextNode()
}
})
})
.then(() => nodes)
}
_execPipeline (nodes) {
let dirtyNodes = nodes[this.dirtyNodes]
if (dirtyNodes) {
let node
return async.whilst(() => (node = dirtyNodes.shift()), nextNode => {
let current
let pipeline = node[this.pipeline]
if (pipeline) {
async.whilst(() => (current = pipeline.shift()), execCallback => current(node, execCallback))
.then(() => nextNode(), err => nextNode(err))
} else {
nextNode()
}
})
}
}
_processTargets (targets, runtime) {
return async.each(targets, (target, nextTarget) => {
let filePromise
// FIXME like, better type checking
if (typeof target === 'string') {
filePromise = this.resolvePath(target)
.then(fullPath => this.resolveFile(fullPath))
} else {
filePromise = Promise.resolve(target)
}
filePromise
.then(file => {
runtime.output.add(file)
let ast = this._parser.parse(file.contents.toString())
file[this.ast] = ast
ast[this.file] = file
return ast
})
.then(nodes => this._initPipeline(nodes, runtime))
.then(nodes => this._execPipeline(nodes))
.then(() => nextTarget(), err => nextTarget(err))
})
}
_runPostHooks (runtime) {
let hook
return async.whilst(() => (hook = runtime.postHooks.shift()), nextHook => hook(nextHook))
}
_serializeASTs (runtime) {
return async.each(runtime.output, (output, nextOutput) => {
if (output[this.ast]) {
output.contents = new Buffer(util.parse5.serialize(output[this.ast]))
}
nextOutput()
})
}
process (targets) {
let runtime = {
output: new Set(),
postHooks: new Denque()
}
if (!Array.isArray(targets)) {
targets = [targets]
}
return this._processTargets(targets, runtime)
.then(() => this._runPostHooks(runtime))
.then(() => this._serializeASTs(runtime))
.then(() => [...runtime.output])
}
}