vulcanize
Version:
Process Web Components into one output file
566 lines (530 loc) • 19.7 kB
JavaScript
/**
* @license
* Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/
// jshint node: true
'use strict';
var path = require('path');
var url = require('url');
var pathPosix = path.posix || require('path-posix');
var hyd = require('hydrolysis');
var dom5 = require('dom5');
var CommentMap = require('./comment-map');
var constants = require('./constants');
var matchers = require('./matchers');
var PathResolver = require('./pathresolver');
var encodeString = require('../third_party/UglifyJS2/output');
var Promise = global.Promise || require('es6-promise').Promise;
/**
* This is the copy of vulcanize we keep to simulate the setOptions api.
*
* TODO(garlicnation): deprecate and remove setOptions API in favor of constructor.
*/
var singleton;
function buildLoader(config) {
var abspath = config.abspath;
var excludes = config.excludes;
var fsResolver = config.fsResolver;
var redirects = config.redirects;
var loader = new hyd.Loader();
if (fsResolver) {
loader.addResolver(fsResolver);
} else {
var fsOptions = {};
if (abspath) {
fsOptions.root = path.resolve(abspath);
fsOptions.basePath = '/';
}
loader.addResolver(new hyd.FSResolver(fsOptions));
}
// build null HTTPS? resolver to skip external scripts
loader.addResolver(new hyd.NoopResolver(constants.EXTERNAL_URL));
var redirectOptions = {};
if (abspath) {
redirectOptions.root = path.resolve(abspath);
redirectOptions.basePath = '/';
}
var redirectConfigs = [];
for (var i = 0; i < redirects.length; i++) {
var split = redirects[i].split('|');
var uri = url.parse(split[0]);
var replacement = split[1];
if (!uri || !replacement) {
throw new Error("Invalid redirect config: " + redirects[i]);
}
var redirectConfig = new hyd.RedirectResolver.ProtocolRedirect({
protocol: uri.protocol,
hostname: uri.hostname,
path: uri.pathname,
redirectPath: replacement
});
redirectConfigs.push(redirectConfig);
}
if (redirectConfigs.length > 0) {
redirectOptions.redirects = redirectConfigs;
loader.addResolver(new hyd.RedirectResolver(redirectOptions));
}
if (excludes) {
excludes.forEach(function(r) {
loader.addResolver(new hyd.NoopResolver(r));
});
}
return loader;
}
function nextSibling(node) {
var parentNode = node.parentNode;
if (!parentNode) {
return null;
}
var idx = parentNode.childNodes.indexOf(node);
return parentNode.childNodes[idx + 1] || null;
}
var Vulcan = function Vulcan(opts) {
// implicitStrip should be true by default
this.implicitStrip = opts.implicitStrip === undefined ? true : Boolean(opts.implicitStrip);
this.abspath = (String(opts.abspath) === opts.abspath && String(opts.abspath).trim() !== '') ? path.resolve(opts.abspath) : null;
this.pathResolver = new PathResolver(this.abspath);
this.addedImports = Array.isArray(opts.addedImports) ? opts.addedImports : [];
this.excludes = Array.isArray(opts.excludes) ? opts.excludes : [];
this.stripExcludes = Array.isArray(opts.stripExcludes) ? opts.stripExcludes : [];
this.stripComments = Boolean(opts.stripComments);
this.enableCssInlining = Boolean(opts.inlineCss);
this.enableScriptInlining = Boolean(opts.inlineScripts);
this.inputUrl = String(opts.inputUrl) === opts.inputUrl ? opts.inputUrl : '';
this.fsResolver = opts.fsResolver;
this.redirects = Array.isArray(opts.redirects) ? opts.redirects : [];
this.polymer2 = opts.polymer2;
if (opts.loader) {
this.loader = opts.loader;
} else {
this.loader = buildLoader({
abspath: this.abspath,
fsResolver: this.fsResolver,
excludes: this.excludes,
redirects: this.redirects
});
}
};
Vulcan.prototype = {
isDuplicateImport: function isDuplicateImport(importMeta) {
return !importMeta.href;
},
reparent: function reparent(newParent) {
return function(node) {
node.parentNode = newParent;
};
},
isExcludedImport: function isExcludedImport(importMeta) {
return this.isExcludedHref(importMeta.href);
},
isExcludedHref: function isExcludedHref(href) {
if (constants.EXTERNAL_URL.test(href)) {
return true;
}
if (!this.excludes) {
return false;
}
return this.excludes.some(function(r) {
return href.search(r) >= 0;
});
},
isStrippedImport: function isStrippedImport(importMeta) {
if (!this.stripExcludes.length) {
return false;
}
var href = importMeta.href;
return this.stripExcludes.some(function(r) {
return href.search(r) >= 0;
});
},
isBlankTextNode: function isBlankTextNode(node) {
return node && dom5.isTextNode(node) && !/\S/.test(dom5.getTextContent(node));
},
hasOldPolymer: function hasOldPolymer(doc) {
return Boolean(dom5.query(doc, matchers.polymerElement));
},
removeElementAndNewline: function removeElementAndNewline(node, replacement) {
// when removing nodes, remove the newline after it as well
var parent = node.parentNode;
var nextIdx = parent.childNodes.indexOf(node) + 1;
var next = parent.childNodes[nextIdx];
// remove next node if it is blank text
if (this.isBlankTextNode(next)) {
dom5.remove(next);
}
if (replacement) {
dom5.replace(node, replacement);
} else {
dom5.remove(node);
}
},
isLicenseComment: function(node) {
if (dom5.isCommentNode(node)) {
return dom5.getTextContent(node).indexOf('@license') > -1;
}
return false;
},
moveToBodyMatcher: dom5.predicates.AND(
dom5.predicates.NOT(
dom5.predicates.parentMatches(
dom5.predicates.hasTagName('template'))),
dom5.predicates.OR(
dom5.predicates.hasTagName('script'),
dom5.predicates.hasTagName('link'),
matchers.CSS
),
dom5.predicates.NOT(
dom5.predicates.OR(
matchers.polymerExternalStyle,
dom5.predicates.hasAttrValue('rel', 'dns-prefetch'),
dom5.predicates.hasAttrValue('rel', 'icon'),
dom5.predicates.hasAttrValue('rel', 'manifest'),
dom5.predicates.hasAttrValue('rel', 'preconnect'),
dom5.predicates.hasAttrValue('rel', 'prefetch'),
dom5.predicates.hasAttrValue('rel', 'preload'),
dom5.predicates.hasAttrValue('rel', 'prerender')
)
)
),
ancestorWalk: function(node, target) {
while(node) {
if (node === target) {
return true;
}
node = node.parentNode;
}
return false;
},
isTemplated: function(node) {
while(node) {
if (dom5.isDocumentFragment(node)) {
return true;
}
node = node.parentNode;
}
return false;
},
isInsideTemplate: dom5.predicates.parentMatches(
dom5.predicates.hasTagName('template')),
flatten: function flatten(tree, mainDocUrl) {
var isMainDoc = (mainDocUrl === undefined);
if (isMainDoc) {
mainDocUrl = tree.href;
}
var doc = tree.html.ast;
var imports = tree.imports;
var head = dom5.query(doc, matchers.head);
var body = dom5.query(doc, matchers.body);
var importNodes = tree.html.import;
// early check for old polymer versions
if (this.hasOldPolymer(doc)) {
throw new Error(constants.OLD_POLYMER + ' File: ' + this.pathResolver.urlToPath(tree.href));
}
this.fixFakeExternalScripts(doc);
this.pathResolver.acid(doc, tree.href, this.polymer2);
var moveTarget;
if (isMainDoc) {
// hide bodies of imports from rendering in main document
moveTarget = dom5.constructors.element('div');
dom5.setAttribute(moveTarget, 'hidden', '');
dom5.setAttribute(moveTarget, 'by-vulcanize', '');
} else {
moveTarget = dom5.constructors.fragment();
}
var htmlImportEncountered = false;
// Once we encounter an html import, we need to move things into the body,
// because html imports contain things that can't be in document
// head.
dom5.queryAll(head, this.moveToBodyMatcher).forEach(function(n) {
if (!htmlImportEncountered && matchers.htmlImport(n)) {
htmlImportEncountered = true;
}
if (htmlImportEncountered) {
this.removeElementAndNewline(n);
dom5.append(moveTarget, n);
}
}, this);
this.prepend(body, moveTarget);
if (imports) {
for (var i = 0, im, thisImport; i < imports.length; i++) {
im = imports[i];
thisImport = importNodes[i];
if (this.isInsideTemplate(thisImport)) {
continue;
}
if (this.isDuplicateImport(im) || this.isStrippedImport(im)) {
this.removeElementAndNewline(thisImport);
continue;
}
if (this.isExcludedImport(im)) {
continue;
}
if (this.isTemplated(thisImport)) {
continue;
}
var bodyFragment = dom5.constructors.fragment();
var importDoc = this.flatten(im, mainDocUrl);
// rewrite urls
this.pathResolver.resolvePaths(importDoc, im.href, tree.href, this.polymer2);
var importHead = dom5.query(importDoc, matchers.head);
var importBody = dom5.query(importDoc, matchers.body);
// merge head and body tags for imports into main document
var importHeadChildren = importHead.childNodes;
var importBodyChildren = importBody.childNodes;
// make sure @license comments from import document make it into the import
var importHtml = importHead.parentNode;
var licenseComments = importDoc.childNodes.concat(importHtml.childNodes).filter(this.isLicenseComment);
// move children of <head> and <body> into importer's <body>
var reparentFn = this.reparent(bodyFragment);
importHeadChildren.forEach(reparentFn);
importBodyChildren.forEach(reparentFn);
bodyFragment.childNodes = bodyFragment.childNodes.concat(
licenseComments,
importHeadChildren,
importBodyChildren
);
// hide imports in main document, unless already hidden
if (isMainDoc && !this.ancestorWalk(thisImport, moveTarget)) {
this.hide(thisImport);
}
this.removeElementAndNewline(thisImport, bodyFragment);
}
}
// If hidden node is empty, remove it
if (isMainDoc && moveTarget.childNodes.length === 0) {
dom5.remove(moveTarget);
}
return doc;
},
hide: function(node) {
var hidden = dom5.constructors.element('div');
dom5.setAttribute(hidden, 'hidden', '');
dom5.setAttribute(hidden, 'by-vulcanize', '');
this.removeElementAndNewline(node, hidden);
dom5.append(hidden, node);
},
prepend: function prepend(parent, node) {
if (parent.childNodes.length) {
dom5.insertBefore(parent, parent.childNodes[0], node);
} else {
dom5.append(parent, node);
}
},
fixFakeExternalScripts: function fixFakeExternalScripts(doc) {
var scripts = dom5.queryAll(doc, matchers.JS_INLINE);
scripts.forEach(function(script) {
if (script.__hydrolysisInlined) {
dom5.setAttribute(script, 'src', script.__hydrolysisInlined);
dom5.setTextContent(script, '');
}
});
},
// inline scripts into document, returns a promise resolving to document.
inlineScripts: function inlineScripts(doc, href) {
var scripts = dom5.queryAll(doc, matchers.JS_SRC);
var scriptPromises = scripts.map(function(script) {
var src = dom5.getAttribute(script, 'src');
var uri = url.resolve(href, src);
// let the loader handle the requests
if (this.isExcludedHref(src)) {
return Promise.resolve(true);
}
return this.loader.request(uri).then(function(content) {
if (content) {
content = encodeString(content);
dom5.removeAttribute(script, 'src');
dom5.setTextContent(script, content);
}
});
}.bind(this));
// When all scripts are read, return the document
return Promise.all(scriptPromises).then(function(){ return {doc: doc, href: href}; });
},
// inline scripts into document, returns a promise resolving to document.
inlineCss: function inlineCss(doc, href) {
var lastPolymerExternalStyle = null;
var css_links = dom5.queryAll(doc, matchers.ALL_CSS_LINK);
var cssPromises = css_links.map(function(link) {
var tag = link;
var src = dom5.getAttribute(tag, 'href');
var media = dom5.getAttribute(tag, 'media');
var uri = url.resolve(href, src);
var isPolymerExternalStyle = matchers.polymerExternalStyle(tag);
var polymer2 = this.polymer2;
// let the loader handle the requests
if (this.isExcludedHref(src)) {
return Promise.resolve(true);
}
// let the loader handle the requests
return this.loader.request(uri).then(function(content) {
if (content) {
if (media) {
content = '@media ' + media + ' {' + content + '}';
}
var style = dom5.constructors.element('style');
if (isPolymerExternalStyle) {
// a polymer expternal style <link type="css" rel="import"> must be
// in a <dom-module> to be processed
var ownerDomModule = dom5.nodeWalkPrior(tag, dom5.predicates.hasTagName('dom-module'));
if (ownerDomModule) {
var domTemplate = dom5.query(ownerDomModule, dom5.predicates.hasTagName('template'));
if (polymer2) {
var assetpath = dom5.getAttribute(ownerDomModule, 'assetpath') || '';
content = this.pathResolver.rewriteURL(uri, url.resolve(href, assetpath), content);
} else {
content = this.pathResolver.rewriteURL(uri, href, content);
}
if (!domTemplate) {
// create a <template>, which has a fragment as childNodes[0]
domTemplate = dom5.constructors.element('template');
domTemplate.childNodes.push(dom5.constructors.fragment());
dom5.append(ownerDomModule, domTemplate);
}
dom5.remove(tag);
if (!lastPolymerExternalStyle) {
// put the style at the top of the dom-module's template
this.prepend(domTemplate.childNodes[0], style);
} else {
// put this style behind the last polymer external style
dom5.insertBefore(domTemplate.childNodes[0], nextSibling(lastPolymerExternalStyle), style);
}
lastPolymerExternalStyle = style;
}
} else {
content = this.pathResolver.rewriteURL(uri, href, content);
dom5.replace(tag, style);
}
dom5.setTextContent(style, '\n' + content + '\n');
}
}.bind(this));
}.bind(this));
// When all style imports are read, return the document
return Promise.all(cssPromises).then(function(){ return {doc: doc, href: href}; });
},
getImplicitExcludes: function getImplicitExcludes(excludes) {
// Build a loader that doesn't have to stop at our HTML excludes, since we
// need them. JS excludes should still be excluded.
var loader = buildLoader({
abspath: this.abspath,
fsResolver: this.fsResolver,
redirects: this.redirects,
excludes: excludes.filter(function(e) { return e.match(/.js$/i); })
});
var analyzer = new hyd.Analyzer(true, loader);
var analyzedExcludes = [];
excludes.forEach(function(exclude) {
if (exclude.match(/.js$/i)) {
return;
}
if (exclude.match(/.css$/i)) {
return;
}
if (exclude.slice(-1) === '/') {
return;
}
var depPromise = analyzer._getDependencies(exclude);
depPromise.catch(function(err) {
// include that this was an excluded url in the error message.
err.message += '. Could not read dependencies for excluded URL: ' + exclude;
});
analyzedExcludes.push(depPromise);
});
return Promise.all(analyzedExcludes).then(function(strippedExcludes) {
var dedupe = {};
strippedExcludes.forEach(function(excludeList){
excludeList.forEach(function(exclude) {
dedupe[exclude] = true;
});
});
return Object.keys(dedupe);
});
},
_process: function _process(target, cb) {
var chain = Promise.resolve(true);
if (this.implicitStrip && this.excludes) {
chain = this.getImplicitExcludes(this.excludes).then(function(implicitExcludes) {
implicitExcludes.forEach(function(strippedExclude) {
this.stripExcludes.push(strippedExclude);
}.bind(this));
}.bind(this));
}
var analyzer = new hyd.Analyzer(true, this.loader);
chain = chain.then(function(){
return analyzer.metadataTree(target);
}).then(function(tree) {
var flatDoc = this.flatten(tree);
// make sure there's a <meta charset> in the page to force UTF-8
var meta = dom5.query(flatDoc, matchers.meta);
var head = dom5.query(flatDoc, matchers.head);
for (var i = 0; i < this.addedImports.length; i++) {
var newImport = dom5.constructors.element('link');
dom5.setAttribute(newImport, 'rel', 'import');
dom5.setAttribute(newImport, 'href', this.addedImports[i]);
this.prepend(head, newImport);
}
if (!meta) {
meta = dom5.constructors.element('meta');
dom5.setAttribute(meta, 'charset', 'UTF-8');
this.prepend(head, meta);
}
return {doc: flatDoc, href: tree.href};
}.bind(this));
if (this.enableScriptInlining) {
chain = chain.then(function(docObj) {
return this.inlineScripts(docObj.doc, docObj.href);
}.bind(this));
}
if (this.enableCssInlining) {
chain = chain.then(function(docObj) {
return this.inlineCss(docObj.doc, docObj.href);
}.bind(this));
}
if (this.stripComments) {
chain = chain.then(function(docObj) {
var comments = new CommentMap();
var doc = docObj.doc;
var head = dom5.query(doc, matchers.head);
// remove all comments
dom5.nodeWalkAll(doc, dom5.isCommentNode).forEach(function(comment) {
comments.set(comment.data, comment);
dom5.remove(comment);
});
// Deduplicate license comments
comments.keys().forEach(function (commentData) {
if (commentData.indexOf("@license") == -1) {
return;
}
this.prepend(head, comments.get(commentData));
}, this);
return docObj;
}.bind(this));
}
chain.then(function(docObj) {
cb(null, dom5.serialize(docObj.doc));
}).catch(cb);
},
process: function process(target, cb) {
if (this.inputUrl) {
this._process(this.inputUrl, cb);
} else {
if (this.abspath) {
target = pathPosix.resolve('/', target);
} else {
target = this.pathResolver.pathToUrl(path.resolve(target));
}
this._process(target, cb);
}
}
};
Vulcan.process = function process(target, cb) {
singleton.process(target, cb);
};
Vulcan.setOptions = function setOptions(opts) {
singleton = new Vulcan(opts);
};
module.exports = Vulcan;