Files
asciidisco.com/build/node_modules/hydrolysis/lib/analyzer.js
2023-08-01 13:49:46 +02:00

774 lines
24 KiB
JavaScript

/**
* @license
* Copyright (c) 2015 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';
// jshint -W079
var Promise = global.Promise || require('es6-promise').Promise;
// jshint +W079
var dom5 = require('dom5');
var url = require('url');
var docs = require('./ast-utils/docs');
var FileLoader = require('./loader/file-loader');
var importParse = require('./ast-utils/import-parse');
var jsParse = require('./ast-utils/js-parse');
var NoopResolver = require('./loader/noop-resolver');
var StringResolver = require('./loader/string-resolver');
function reduceMetadata(m1, m2) {
return {
elements: m1.elements.concat(m2.elements),
features: m1.features.concat(m2.features),
behaviors: m1.behaviors.concat(m2.behaviors),
};
}
var EMPTY_METADATA = {elements: [], features: [], behaviors: []};
/**
* Parse5's representation of a parsed html document
* @typedef {Object} DocumentAST
* @memberof hydrolysis
*/
/**
* espree's representation of a parsed html document
* @typedef {Object} JSAST
* @memberof hydrolysis
*/
/**
* Package of a parsed JS script
* @typedef {Object} ParsedJS
* @property {JSAST} ast The script's AST
* @property {DocumentAST} scriptElement If inline, the script's containing tag.
* @memberof hydrolysis
*/
/**
* The metadata for a single polymer element
* @typedef {Object} ElementDescriptor
* @memberof hydrolysis
*/
/**
* The metadata for a Polymer feature.
* @typedef {Object} FeatureDescriptor
* @memberof hydrolysis
*/
/**
* The metadata for a Polymer behavior mixin.
* @typedef {Object} BehaviorDescriptor
* @memberof hydrolysis
*/
/**
* The metadata for all features and elements defined in one document
* @typedef {Object} DocumentDescriptor
* @memberof hydrolysis
* @property {Array<ElementDescriptor>} elements The elements from the document
* @property {Array<FeatureDescriptor>} features The features from the document
* @property {Array<FeatureDescriptor>} behaviors The behaviors from the document
*/
/**
* The metadata of an entire HTML document, in promises.
* @typedef {Object} AnalyzedDocument
* @memberof hydrolysis
* @property {string} href The url of the document.
* @property {Promise<ParsedImport>} htmlLoaded The parsed representation of
* the doc. Use the `ast` property to get the full `parse5` ast
*
* @property {Promise<Array<string>>} depsLoaded Resolves to the list of this
* Document's transitive import dependencies
*
* @property {Array<string>} depHrefs The direct dependencies of the document.
*
* @property {Promise<DocumentDescriptor>} metadataLoaded Resolves to the list of
* this Document's import dependencies
*/
/**
* A database of Polymer metadata defined in HTML
*
* @constructor
* @memberOf hydrolysis
* @param {boolean} attachAST If true, attach a parse5 compliant AST
* @param {FileLoader=} loader An optional `FileLoader` used to load external
* resources
*/
var Analyzer = function Analyzer(attachAST,
loader) {
this.loader = loader;
/**
* A list of all elements the `Analyzer` has metadata for.
* @member {Array.<ElementDescriptor>}
*/
this.elements = [];
/**
* A view into `elements`, keyed by tag name.
* @member {Object.<string,ElementDescriptor>}
*/
this.elementsByTagName = {};
/**
* A list of API features added to `Polymer.Base` encountered by the
* analyzer.
* @member {Array<FeatureDescriptor>}
*/
this.features = [];
/**
* The behaviors collected by the analysis pass.
*
* @member {Array<BehaviorDescriptor>}
*/
this.behaviors = [];
/**
* The behaviors collected by the analysis pass by name.
*
* @member {Object<string,BehaviorDescriptor>}
*/
this.behaviorsByName = {};
/**
* A map, keyed by absolute path, of Document metadata.
* @member {Object<string,AnalyzedDocument>}
*/
this.html = {};
/**
* A map, keyed by path, of HTML document ASTs.
* @type {Object}
*/
this.parsedDocuments = {};
/**
* A map, keyed by path, of JS script ASTs.
*
* If the path is an HTML file with multiple scripts, the entry will be an array of scripts.
*
* @type {Object<string,Array<ParsedJS>>}
*/
this.parsedScripts = {};
/**
* A map, keyed by path, of document content.
* @type {Object}
*/
this._content = {};
};
/**
* Options for `Analyzer.analzye`
* @typedef {Object} LoadOptions
* @memberof hydrolysis
* @property {boolean} noAnnotations Whether `annotate()` should be skipped.
* @property {String=} content Content to resolve `href` to instead of loading
* from the file system.
* @property {boolean} clean Whether the generated descriptors should be cleaned
* of redundant data.
* @property {string=} resolver.
* `xhr` to use XMLHttpRequest
* `fs` to use the local filesystem.
* `permissive` to use the local filesystem and return empty files when a
* path can't be found.
* Default is `fs` in node and `xhr` in the browser.
* @property {function(string): boolean} filter A predicate function that
* indicates which files should be ignored by the loader. By default all
* files not located under the dirname of `href` will be ignored.
*/
/**
* Shorthand for transitively loading and processing all imports beginning at
* `href`.
*
* In order to properly filter paths, `href` _must_ be an absolute URI.
*
* @param {string} href The root import to begin loading from.
* @param {LoadOptions=} options Any additional options for the load.
* @return {Promise<Analyzer>} A promise that will resolve once `href` and its
* dependencies have been loaded and analyzed.
*/
Analyzer.analyze = function analyze(href, options) {
options = options || {};
options.filter = options.filter || _defaultFilter(href);
var loader = new FileLoader();
var resolver = options.resolver;
if (resolver === undefined) {
if (typeof window === 'undefined') {
resolver = 'fs';
} else {
resolver = 'xhr';
}
}
var PrimaryResolver;
if (resolver === 'fs') {
PrimaryResolver = require('./loader/fs-resolver');
} else if (resolver === 'xhr') {
PrimaryResolver = require('./loader/xhr-resolver');
} else if (resolver === 'permissive') {
PrimaryResolver = require('./loader/error-swallowing-fs-resolver');
} else {
throw new Error("Resolver must be one of 'fs' or 'xhr'");
}
loader.addResolver(new PrimaryResolver(options));
if (options.content) {
loader.addResolver(new StringResolver({url: href, content: options.content}));
}
loader.addResolver(new NoopResolver({test: options.filter}));
var analyzer = new this(null, loader);
return analyzer.metadataTree(href).then(function(root) {
if (!options.noAnnotations) {
analyzer.annotate();
}
if (options.clean) {
analyzer.clean();
}
return Promise.resolve(analyzer);
});
};
/**
* @private
* @param {string} href
* @return {function(string): boolean}
*/
function _defaultFilter(href) {
// Everything up to the last `/` or `\`.
var base = href.match(/^(.*?)[^\/\\]*$/)[1];
return function(uri) {
return uri.indexOf(base) !== 0;
};
}
Analyzer.prototype.load = function load(href) {
return this.loader.request(href).then(function(content) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
this._content[href] = content;
resolve(this._parseHTML(content, href));
}.bind(this), 0);
}.bind(this)).catch(function(err){
console.error("Error processing document at " + href);
throw err;
});
}.bind(this));
};
/**
* Returns an `AnalyzedDocument` representing the provided document
* @private
* @param {string} htmlImport Raw text of an HTML document
* @param {string} href The document's URL.
* @return {AnalyzedDocument} An `AnalyzedDocument`
*/
Analyzer.prototype._parseHTML = function _parseHTML(htmlImport,
href) {
if (href in this.html) {
return this.html[href];
}
var depsLoaded = [];
var depHrefs = [];
var metadataLoaded = Promise.resolve(EMPTY_METADATA);
var parsed;
try {
parsed = importParse(htmlImport, href);
} catch (err) {
console.error('Error parsing!');
throw err;
}
var htmlLoaded = Promise.resolve(parsed);
if (parsed.script) {
metadataLoaded = this._processScripts(parsed.script, href);
}
var commentText = parsed.comment.map(function(comment){
return dom5.getTextContent(comment);
});
var pseudoElements = docs.parsePseudoElements(commentText);
pseudoElements.forEach(function(element){
element.contentHref = href;
this.elements.push(element);
this.elementsByTagName[element.is] = element;
}.bind(this));
metadataLoaded = metadataLoaded.then(function(metadata){
var metadataEntry = {
elements: pseudoElements,
features: [],
behaviors: []
};
return [metadata, metadataEntry].reduce(reduceMetadata);
});
depsLoaded.push(metadataLoaded);
if (this.loader) {
var baseUri = href;
if (parsed.base.length > 1) {
console.error("Only one base tag per document!");
throw "Multiple base tags in " + href;
} else if (parsed.base.length == 1) {
var baseHref = dom5.getAttribute(parsed.base[0], "href");
if (baseHref) {
baseHref = baseHref + "/";
baseUri = url.resolve(baseUri, baseHref);
}
}
parsed.import.forEach(function(link) {
var linkurl = dom5.getAttribute(link, 'href');
if (linkurl) {
var resolvedUrl = url.resolve(baseUri, linkurl);
depHrefs.push(resolvedUrl);
depsLoaded.push(this._dependenciesLoadedFor(resolvedUrl, href));
}
}.bind(this));
parsed.style.forEach(function(styleElement) {
if (polymerExternalStyle(styleElement)) {
var styleHref = dom5.getAttribute(styleElement, 'href');
if (href) {
styleHref = url.resolve(baseUri, styleHref);
depsLoaded.push(this.loader.request(styleHref).then(function(content){
this._content[styleHref] = content;
}.bind(this)));
}
}
}.bind(this));
}
depsLoaded = Promise.all(depsLoaded)
.then(function() {return depHrefs;})
.catch(function(err) {throw err;});
this.parsedDocuments[href] = parsed.ast;
this.html[href] = {
href: href,
htmlLoaded: htmlLoaded,
metadataLoaded: metadataLoaded,
depHrefs: depHrefs,
depsLoaded: depsLoaded
};
return this.html[href];
};
Analyzer.prototype._processScripts = function _processScripts(scripts, href) {
var scriptPromises = [];
scripts.forEach(function(script) {
scriptPromises.push(this._processScript(script, href));
}.bind(this));
return Promise.all(scriptPromises).then(function(metadataList) {
return metadataList.reduce(reduceMetadata, EMPTY_METADATA);
});
};
Analyzer.prototype._processScript = function _processScript(script, href) {
var src = dom5.getAttribute(script, 'src');
var parsedJs;
if (!src) {
try {
parsedJs = jsParse((script.childNodes.length) ? script.childNodes[0].value : '');
} catch (err) {
// Figure out the correct line number for the error.
var line = 0;
var col = 0;
if (script.__ownerDocument && script.__ownerDocument == href) {
line = script.__locationDetail.line - 1;
col = script.__locationDetail.column - 1;
}
line += err.lineNumber;
col += err.column;
var message = "Error parsing script in " + href + " at " + line + ":" + col;
message += "\n" + err.stack;
var fixedErr = new Error(message);
fixedErr.location = {line: line, column: col};
fixedErr.ownerDocument = script.__ownerDocument;
return Promise.reject(fixedErr);
}
if (parsedJs.elements) {
parsedJs.elements.forEach(function(element) {
element.scriptElement = script;
element.contentHref = href;
this.elements.push(element);
if (element.is in this.elementsByTagName) {
console.warn('Ignoring duplicate element definition: ' + element.is);
} else {
this.elementsByTagName[element.is] = element;
}
}.bind(this));
}
if (parsedJs.features) {
parsedJs.features.forEach(function(feature){
feature.contentHref = href;
feature.scriptElement = script;
});
this.features = this.features.concat(parsedJs.features);
}
if (parsedJs.behaviors) {
parsedJs.behaviors.forEach(function(behavior){
behavior.contentHref = href;
this.behaviorsByName[behavior.is] = behavior;
this.behaviorsByName[behavior.symbol] = behavior;
}.bind(this));
this.behaviors = this.behaviors.concat(parsedJs.behaviors);
}
if (!Object.hasOwnProperty.call(this.parsedScripts, href)) {
this.parsedScripts[href] = [];
}
var scriptElement;
if (script.__ownerDocument && script.__ownerDocument == href) {
scriptElement = script;
}
this.parsedScripts[href].push({
ast: parsedJs.parsedScript,
scriptElement: scriptElement
});
return parsedJs;
}
if (this.loader) {
var resolvedSrc = url.resolve(href, src);
return this.loader.request(resolvedSrc).then(function(content) {
this._content[resolvedSrc] = content;
var resolvedScript = Object.create(script);
resolvedScript.childNodes = [{value: content}];
resolvedScript.attrs = resolvedScript.attrs.slice();
dom5.removeAttribute(resolvedScript, 'src');
return this._processScript(resolvedScript, resolvedSrc);
}.bind(this)).catch(function(err) {throw err;});
} else {
return Promise.resolve(EMPTY_METADATA);
}
};
Analyzer.prototype._dependenciesLoadedFor = function _dependenciesLoadedFor(href, root) {
var found = {};
if (root !== undefined) {
found[root] = true;
}
return this._getDependencies(href, found).then(function(deps) {
var depMetadataLoaded = [];
var depPromises = deps.map(function(depHref){
return this.load(depHref).then(function(htmlMonomer) {
return htmlMonomer.metadataLoaded;
});
}.bind(this));
return Promise.all(depPromises);
}.bind(this));
};
/**
* List all the html dependencies for the document at `href`.
* @param {string} href The href to get dependencies for.
* @param {Object.<string,boolean>=} found An object keyed by URL of the
* already resolved dependencies.
* @param {boolean=} transitive Whether to load transitive
* dependencies. Defaults to true.
* @return {Array.<string>} A list of all the html dependencies.
*/
Analyzer.prototype._getDependencies = function _getDependencies(href, found, transitive) {
if (found === undefined) {
found = {};
found[href] = true;
}
if (transitive === undefined) {
transitive = true;
}
var deps = [];
return this.load(href).then(function(htmlMonomer) {
var transitiveDeps = [];
htmlMonomer.depHrefs.forEach(function(depHref){
if (found[depHref]) {
return;
}
deps.push(depHref);
found[depHref] = true;
if (transitive) {
transitiveDeps.push(this._getDependencies(depHref, found));
}
}.bind(this));
return Promise.all(transitiveDeps);
}.bind(this)).then(function(transitiveDeps) {
var alldeps = transitiveDeps.reduce(function(a, b) {
return a.concat(b);
}, []).concat(deps);
return alldeps;
});
};
function matchesDocumentFolder(descriptor, href) {
if (!descriptor.contentHref) {
return false;
}
var descriptorDoc = url.parse(descriptor.contentHref);
if (!descriptorDoc || !descriptorDoc.pathname) {
return false;
}
var searchDoc = url.parse(href);
if (!searchDoc || !searchDoc.pathname) {
return false;
}
var searchPath = searchDoc.pathname;
var lastSlash = searchPath.lastIndexOf("/");
if (lastSlash > 0) {
searchPath = searchPath.slice(0, lastSlash);
}
return descriptorDoc.pathname.indexOf(searchPath) === 0;
}
/**
* Returns the elements defined in the folder containing `href`.
* @param {string} href path to search.
* @return {Array.<ElementDescriptor>}
*/
Analyzer.prototype.elementsForFolder = function elementsForFolder(href) {
return this.elements.filter(function(element){
return matchesDocumentFolder(element, href);
});
};
/**
* Returns the behaviors defined in the folder containing `href`.
* @param {string} href path to search.
* @return {Array.<BehaviorDescriptor>}
*/
Analyzer.prototype.behaviorsForFolder = function behaviorsForFolder(href) {
return this.behaviors.filter(function(behavior){
return matchesDocumentFolder(behavior, href);
});
};
/**
* Returns a promise that resolves to a POJO representation of the import
* tree, in a format that maintains the ordering of the HTML imports spec.
* @param {string} href the import to get metadata for.
* @return {Promise}
*/
Analyzer.prototype.metadataTree = function metadataTree(href) {
return this.load(href).then(function(monomer){
var loadedHrefs = {};
loadedHrefs[href] = true;
return this._metadataTree(monomer, loadedHrefs);
}.bind(this));
};
Analyzer.prototype._metadataTree = function _metadataTree(htmlMonomer,
loadedHrefs) {
if (loadedHrefs === undefined) {
loadedHrefs = {};
}
return htmlMonomer.metadataLoaded.then(function(metadata) {
metadata = {
elements: metadata.elements,
features: metadata.features,
href: htmlMonomer.href
};
return htmlMonomer.depsLoaded.then(function(hrefs) {
var depMetadata = [];
hrefs.forEach(function(href) {
var metadataPromise = Promise.resolve(true);
if (depMetadata.length > 0) {
metadataPromise = depMetadata[depMetadata.length - 1];
}
metadataPromise = metadataPromise.then(function() {
if (!loadedHrefs[href]) {
loadedHrefs[href] = true;
return this._metadataTree(this.html[href], loadedHrefs);
} else {
return Promise.resolve({});
}
}.bind(this));
depMetadata.push(metadataPromise);
}.bind(this));
return Promise.all(depMetadata).then(function(importMetadata) {
metadata.imports = importMetadata;
return htmlMonomer.htmlLoaded.then(function(parsedHtml) {
metadata.html = parsedHtml;
if (metadata.elements) {
metadata.elements.forEach(function(element) {
attachDomModule(parsedHtml, element);
});
}
return metadata;
});
});
}.bind(this));
}.bind(this));
};
function matchingImport(importElement) {
var matchesTag = dom5.predicates.hasTagName(importElement.tagName);
var matchesHref = dom5.predicates.hasAttrValue('href', dom5.getAttribute(importElement, 'href'));
var matchesRel = dom5.predicates.hasAttrValue('rel', dom5.getAttribute(importElement, 'rel'));
return dom5.predicates.AND(matchesTag, matchesHref, matchesRel);
}
// TODO(ajo): Refactor out of vulcanize into dom5.
var polymerExternalStyle = dom5.predicates.AND(
dom5.predicates.hasTagName('link'),
dom5.predicates.hasAttrValue('rel', 'import'),
dom5.predicates.hasAttrValue('type', 'css')
);
var externalScript = dom5.predicates.AND(
dom5.predicates.hasTagName('script'),
dom5.predicates.hasAttr('src')
);
var isHtmlImportNode = dom5.predicates.AND(
dom5.predicates.hasTagName('link'),
dom5.predicates.hasAttrValue('rel', 'import'),
dom5.predicates.NOT(
dom5.predicates.hasAttrValue('type', 'css')
)
);
Analyzer.prototype._inlineStyles = function _inlineStyles(ast, href) {
var cssLinks = dom5.queryAll(ast, polymerExternalStyle);
cssLinks.forEach(function(link) {
var linkHref = dom5.getAttribute(link, 'href');
var uri = url.resolve(href, linkHref);
var content = this._content[uri];
var style = dom5.constructors.element('style');
dom5.setTextContent(style, '\n' + content + '\n');
dom5.replace(link, style);
}.bind(this));
return cssLinks.length > 0;
};
Analyzer.prototype._inlineScripts = function _inlineScripts(ast, href) {
var scripts = dom5.queryAll(ast, externalScript);
scripts.forEach(function(script) {
var scriptHref = dom5.getAttribute(script, 'src');
var uri = url.resolve(href, scriptHref);
var content = this._content[uri];
var inlined = dom5.constructors.element('script');
dom5.setTextContent(inlined, '\n' + content + '\n');
dom5.replace(script, inlined);
}.bind(this));
return scripts.length > 0;
};
Analyzer.prototype._inlineImports = function _inlineImports(ast, href, loaded) {
var imports = dom5.queryAll(ast, isHtmlImportNode);
imports.forEach(function(htmlImport) {
var importHref = dom5.getAttribute(htmlImport, 'href');
var uri = url.resolve(href, importHref);
if (loaded[uri]) {
dom5.remove(htmlImport);
return;
}
var content = this.getLoadedAst(uri, loaded);
dom5.replace(htmlImport, content);
}.bind(this));
return imports.length > 0;
};
/**
* Returns a promise resolving to a form of the AST with all links replaced
* with the document they link to. .css and .script files become &lt;style&gt; and
* &lt;script&gt;, respectively.
*
* The elements in the loaded document are unmodified from their original
* documents.
*
* @param {string} href The document to load.
* @param {Object.<string,boolean>=} loaded An object keyed by already loaded documents.
* @return {Promise.<DocumentAST>}
*/
Analyzer.prototype.getLoadedAst = function getLoadedAst(href, loaded) {
if (!loaded) {
loaded = {};
}
loaded[href] = true;
var parsedDocument = this.parsedDocuments[href];
var analyzedDocument = this.html[href];
var astCopy = dom5.parse(dom5.serialize(parsedDocument));
// Whenever we inline something, reset inlined to true to know that anoather
// inlining pass is needed;
this._inlineStyles(astCopy, href);
this._inlineScripts(astCopy, href);
this._inlineImports(astCopy, href, loaded);
return astCopy;
};
/**
* Calls `dom5.nodeWalkAll` on each document that `Anayzler` has laoded.
* @param {Object} predicate A dom5 predicate.
* @return {Object}
*/
Analyzer.prototype.nodeWalkDocuments = function nodeWalkDocuments(predicate) {
for (var href in this.parsedDocuments) {
var match = dom5.nodeWalk(this.parsedDocuments[href], predicate);
if (match) {
return match;
}
}
return null;
};
/**
* Calls `dom5.nodeWalkAll` on each document that `Anayzler` has laoded.
* @param {Object} predicate A dom5 predicate.
* @return {Object}
*/
Analyzer.prototype.nodeWalkAllDocuments = function nodeWalkDocuments(predicate) {
var results = [];
for (var href in this.parsedDocuments) {
var newNodes = dom5.nodeWalkAll(this.parsedDocuments[href], predicate);
results = results.concat(newNodes);
}
return results;
};
/** Annotates all loaded metadata with its documentation. */
Analyzer.prototype.annotate = function annotate() {
if (this.features.length > 0) {
var featureEl = docs.featureElement(this.features);
this.elements.unshift(featureEl);
this.elementsByTagName[featureEl.is] = featureEl;
}
var behaviorsByName = this.behaviorsByName;
var elementHelper = function(descriptor){
docs.annotateElement(descriptor, behaviorsByName);
};
this.elements.forEach(elementHelper);
this.behaviors.forEach(elementHelper); // Same shape.
this.behaviors.forEach(function(behavior){
if (behavior.is !== behavior.symbol && behavior.symbol) {
this.behaviorsByName[behavior.symbol] = undefined;
}
}.bind(this));
};
function attachDomModule(parsedImport, element) {
var domModules = parsedImport['dom-module'];
for (var i = 0, domModule; i < domModules.length; i++) {
domModule = domModules[i];
if (dom5.getAttribute(domModule, 'id') === element.is) {
element.domModule = domModule;
return;
}
}
}
/** Removes redundant properties from the collected descriptors. */
Analyzer.prototype.clean = function clean() {
this.elements.forEach(docs.cleanElement);
};
module.exports = Analyzer;