566 lines
19 KiB
JavaScript
566 lines
19 KiB
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;
|