const fs = require('fs');
const os = require('os');
const glob = require('glob');
const _ = require('lodash');
const errors = require('./errors');
const EventEmitter = require('events').EventEmitter;
const pathModule = require('path');
const Teepee = require('teepee');
const urlTools = require('urltools');
const normalizeUrl = require('normalizeurl').create({ leaveAlone: ',&+' }); // Don't turn ?rotate&resize=10,10 into ?rotate%26resize=10%2C10
const TransformQueue = require('./TransformQueue');
const resolveDataUrl = require('./util/resolveDataUrl');
const compileQuery = require('./compileQuery');
function warnIncompatibleTypes(incompatibleTypes, asset, emittedWarnings) {
const errorString = `Asset is used as both ${[...incompatibleTypes]
.sort()
.join(' and ')}`;
if (!emittedWarnings.has(errorString)) {
emittedWarnings.add(errorString);
const err = new Error(errorString);
err.asset = asset;
asset.assetGraph.warn(err);
}
}
/**
* A graph model of a website, consisting of [Assets]{@link Asset} (edges) and [Relations]{@link Relation}.
*
* @extends EventEmitter
*/
class AssetGraph extends EventEmitter {
/**
* Create a new AssetGraph instance.
*
* Options:
*
* - `root` (optional) The root URL of the graph, either as a fully
* qualified `file:` or `http:` url or file system
* path. Defaults to the current directory,
* ie. `file://<process.cwd()>/`. The purpose of the root
* option is to allow resolution of root-relative urls
* (eg. `<a href="/foo.html">`) from `file:` locations.
*
* Examples:
*
* new AssetGraph()
* // => root: "file:///current/working/dir/"
*
* new AssetGraph({root: '/absolute/fs/path'});
* // => root: "file:///absolute/fs/path/"
*
* new AssetGraph({root: 'relative/path'})
* // => root: "file:///current/working/dir/relative/path/"
*
* @constructor
* @param {Object} options
* @param {String} [options.root] AssetGraph root, allowing root relative URL resolution
* @param {String} [options.canonicalRoot] Canonical URL root of deployed graph. Matching relations will be treated as root relative
*/
constructor(options = {}) {
super();
if (!(this instanceof AssetGraph)) {
return new AssetGraph(options);
}
this.isAssetGraph = true;
Object.assign(this, options);
// this.root might be undefined, in which case urlTools.urlOrFsPathToUrl will use process.cwd()
this.root = normalizeUrl(urlTools.urlOrFsPathToUrl(this.root, true)); // ensureTrailingSlash
this._assets = new Set();
this.idIndex = {};
this._urlIndex = {};
this.teepee = new Teepee({
retry: ['selfRedirect', '5xx'],
headers: {
'User-Agent': `AssetGraph v${
require('../package.json').version
} (https://www.npmjs.com/package/assetgraph)`,
},
});
// Clumsily opt out of teepee's expansion of {...} placeholders in urls.
// We don't want that in assetgraph, and the regexp that teepee uses breaks for certain inputs: https://github.com/assetgraph/assetgraph/issues/1144
this.teepee.expandUrl = function (url) {
return url;
};
}
set canonicalRoot(canonicalRoot) {
// Split into this._canonicalRootBase and this._canonicalRootPath internally
this._canonicalRootPath = undefined;
this._canonicalRootBase = undefined;
if (canonicalRoot) {
if (typeof canonicalRoot !== 'string') {
throw new Error('AssetGraph: canonicalRoot must be a URL string');
}
// Validate that the given canonicalRoot is actually a URL
if (/^\/[^/].*/.test(canonicalRoot)) {
// Root-relative url
this._canonicalRootPath = canonicalRoot;
return;
} else if (!/^(?:https?:)?\/\//i.test(canonicalRoot)) {
throw new Error('AssetGraph: canonicalRoot must be a URL string');
}
if (canonicalRoot[canonicalRoot.length - 1] !== '/') {
canonicalRoot += '/';
}
canonicalRoot = canonicalRoot
.replace(/\/?$/, '/') // Ensure trailing slash on canonical root
.replace(/([^/])(\/[^/].*)\/$/, ($0, $1, $2) => {
// Extract path component into _canonicalRootPath
this._canonicalRootPath = $2;
return `${$1}/`;
});
this._canonicalRootBase = canonicalRoot;
}
}
get canonicalRoot() {
if (this._canonicalRootBase) {
return (
this._canonicalRootBase +
(this._canonicalRootPath ? this._canonicalRootPath.slice(1) : '')
);
} else {
return this._canonicalRootPath;
}
}
/**
* The absolute root url of the graph, always includes a trailing
* slash. A normalized version of the `root` option provided to
* the constructor.
*
* @member {String} AssetGraph#root
*/
/**
* The absolute root url of the graph if it was deployed on its production domain.
*
* Some URLs must be canonical and point to the domain as well,
* which poses some problems when populating a dependency graph from disc.
* Canonical URLs will be treated as cross origin if `canonicalRoot` is not set.
*
* When `canonicalUrl` is set, any encounter of a relation to an asset with a canonical URL
* will be assumed to also exist on disc at the corresponding AssetGraph.root relative URL,
* and thus loaded from there.
*
* Setting a [Relation]{@link Relation} to have `relation.canonical = true` will cause
* the relations `href` to be absolute and prepended with the graphs `canonicalRoot`
*
* @member {String} AssetGraph#canonicalRoot
*/
/**
* Emit a warning event on the event bus.
*
* Warnings events are emitted when AssetGraph encounters an error
* during it's lifecycle, which doesn't stop it dead in its track.
*
* Warning events are usually errors on a website that you want to fix,
* since the model might not correspond to your mental model i they go
* unfixed.
*
* If no event listeners are intercepting warning events, they will escalate to throw.
*
* @param {String | Error} messageOrError The message or error to emit
*/
warn(messageOrError) {
let err;
if (typeof messageOrError === 'string') {
err = new Error(messageOrError);
} else {
err = messageOrError;
}
if (this.listeners('warn').length > 0) {
this.emit('warn', err);
} else {
// jsdom's DOMException throws when attempting to update the message property directly
Object.defineProperty(err, 'message', {
value: `Encountered warning, add a 'warn' event handler to suppress:\n${err.stack}`,
});
throw err;
}
}
/**
* Emit a info event on the event bus.
*
* Info events are helpful hints informing of encounters
* of possible problems, where an automated fix has been applied
* and everything has been handled
*
* @param {String | Error} messageOrError The message or error to emit
*/
info(messageOrError) {
let err;
if (typeof messageOrError === 'string') {
err = new Error(messageOrError);
} else {
err = messageOrError;
}
this.emit('info', err);
}
/**
* Add an asset to the graph.
*
* @param {Asset|String|AssetConfig} assetConfig The Asset, Url or AssetConfig to construct an Asset from
* @return {Asset} The assets instance that was added
*/
addAsset(assetConfig, incomingRelation) {
if (
Array.isArray(assetConfig) ||
(typeof assetConfig === 'string' &&
!/^[a-zA-Z-+]+:/.test(assetConfig) &&
assetConfig.includes('*'))
) {
throw new Error(
'AssetGraph#addAsset does not accept an array or glob patterns, try the addAssets method or the loadAssets transform'
);
}
const baseUrl = (incomingRelation && incomingRelation.baseUrl) || this.root;
if (typeof assetConfig === 'string') {
if (/^[a-zA-Z-+]+:/.test(assetConfig)) {
if (!/^data:/i.test(assetConfig)) {
assetConfig = normalizeUrl(assetConfig);
}
assetConfig = { url: assetConfig };
} else {
assetConfig = {
url: this.resolveUrl(baseUrl, normalizeUrl(encodeURI(assetConfig))),
};
}
}
let asset;
if (assetConfig.isAsset) {
// An already instantiated asset
asset = assetConfig;
asset.assetGraph = this;
} else {
if (typeof assetConfig.url === 'string') {
if (!/^[a-zA-Z-+]+:/.test(assetConfig.url)) {
assetConfig.url = this.resolveUrl(baseUrl, assetConfig.url);
}
assetConfig.url = assetConfig.url.replace(/#.*$/, '');
if (!/^data:/i.test(assetConfig.url)) {
// Browsers interpret control chars and non-ASCII chars in urls as to percent-encoded utf-8 octets
// Make the same transformation early so we treat these urls as identical
assetConfig.url = assetConfig.url.replace(
/[^\x21-\x7f]/g,
encodeURIComponent
);
}
if (this.canonicalRoot) {
if (assetConfig.url.startsWith(this.canonicalRoot)) {
assetConfig.url = assetConfig.url.replace(
this.canonicalRoot,
this.root
);
}
// Canonical urls always end in slash, but should also match urls with no path or trailing slash.
// Make a direct equality comparison without the slash
if (assetConfig.url === this.canonicalRoot.slice(0, -1)) {
assetConfig.url = this.root;
}
}
if (/^data:/.test(assetConfig.url)) {
const parsedDataUrl = resolveDataUrl(assetConfig.url);
if (parsedDataUrl) {
Object.assign(assetConfig, parsedDataUrl);
assetConfig.url = undefined;
assetConfig.type =
assetConfig.type ||
this.lookupContentType(assetConfig.contentType);
} else {
this.warn(
new errors.ParseError(`Cannot parse data url: ${assetConfig.url}`)
);
}
} else if (/^javascript:/i.test(assetConfig.url)) {
assetConfig.text = decodeURIComponent(
assetConfig.url.replace(/^javascript:/i, '')
);
assetConfig.url = undefined;
assetConfig.type = 'JavaScript';
} else {
// Check if an asset with that url already exists in the graph,
// and if it does, update it with the information contained
// in assetConfig:
asset = this._urlIndex[assetConfig.url];
if (asset) {
// New information about an existing asset has arrived
asset.init(assetConfig);
}
}
}
if (!asset) {
if (typeof assetConfig.url === 'undefined' && incomingRelation) {
assetConfig.incomingInlineRelation = incomingRelation;
}
if (assetConfig instanceof this.Asset) {
asset = assetConfig;
} else {
if (assetConfig.type) {
asset = new AssetGraph[assetConfig.type](assetConfig, this);
} else {
asset = new this.Asset(assetConfig, this);
}
if (!incomingRelation && !asset.url) {
// Non-inline asset without an url -- make up a unique url:
asset.externalize();
}
}
}
}
asset._tryUpgrade(undefined, incomingRelation);
if (!this.idIndex[asset.id]) {
this.idIndex[asset.id] = asset;
if (asset.url) {
this._urlIndex[asset.url] = asset;
}
this._assets.add(asset);
asset.isPopulated = false;
this.emit('addAsset', asset);
asset.populate();
}
return asset;
}
/**
* Add assets to the graph.
*
* @param {...(Asset[]|String|String[]|AssetConfig|AssetConfig[])} assetConfigs The asset specs to add
* @return {Asset[]} The assets instances that were added
*/
addAssets(...assetConfigs) {
const assets = [];
for (const assetConfig of _.flattenDeep(assetConfigs)) {
if (
typeof assetConfig === 'string' &&
!/^[a-zA-Z-+]+:/.test(assetConfig) &&
assetConfig.includes('*')
) {
assets.push(
...glob
.sync(
pathModule.resolve(
this.root ? urlTools.fileUrlToFsPath(this.root) : process.cwd(),
assetConfig
),
{
nodir: true,
}
)
.map((path) => this.addAsset(encodeURI(`file://${path}`)))
);
} else {
assets.push(this.addAsset(assetConfig));
}
}
return assets;
}
/**
* Remove an asset from the graph. Also removes the incoming and
* outgoing relations of the asset.
*
* @param {Asset} asset The asset to remove.
* @return {AssetGraph} The AssetGraph instance (chaining-friendly).
*/
removeAsset(asset) {
if (!this.idIndex[asset.id]) {
throw new Error(`AssetGraph.removeAsset: ${asset} not in graph`);
}
if (asset._outgoingRelations) {
const outgoingRelations = [...asset._outgoingRelations];
// Remove the outgoing relations as to not trigger the
// "<relation> will be detached..." warning in the recursive
// removeAsset calls:
asset._outgoingRelations = undefined;
for (const outgoingRelation of outgoingRelations) {
if (
outgoingRelation.to.isAsset &&
outgoingRelation.to.isInline &&
outgoingRelation.to !== outgoingRelation.from
) {
// Remove inline asset
this.removeAsset(outgoingRelation.to);
}
}
// Put back the outgoing relations so that the relations are still
// in a resolved state, even though the asset is no longer in the
// graph. This is debatable since we don't really want to support
// assets living outside of the context of an AssetGraph instance,
// but not doing it makes a test fail here:
// https://github.com/assetgraph/assetgraph/blob/348b8740941effc93106abe84f9225cccf10470d/test/assets/Asset.js#L691-L695
// ... so let's consider whether to nuke that test at some point.
asset._outgoingRelations = outgoingRelations;
}
let stillAttachedIncomingRelations = false;
for (const incomingRelation of asset.incomingRelations) {
if (
incomingRelation.from !== incomingRelation.to &&
incomingRelation.hrefType !== 'inline'
) {
this.warn(
new Error(
`${incomingRelation.toString()} will be detached as a result of removing ${
asset.urlOrDescription
} from the graph`
)
);
try {
incomingRelation.detach();
} catch (e) {
incomingRelation.remove();
stillAttachedIncomingRelations = true;
}
} else {
incomingRelation.remove();
}
}
if (stillAttachedIncomingRelations) {
this.warn(
new Error(
`Leaving ${asset.urlOrDescription} unloaded in the graph, some incoming relations could not be detached`
)
);
asset.unload();
} else {
if (!this._assets.delete(asset)) {
throw new Error(`removeAsset: ${asset} not in graph`);
}
this.idIndex[asset.id] = undefined;
const url = asset.url;
if (url) {
if (this._urlIndex[url]) {
delete this._urlIndex[url];
} else {
throw new Error(`Internal error: ${url} not in _urlIndex`);
}
}
asset.assetGraph = undefined;
this.emit('removeAsset', asset);
}
return this;
}
/**
* Query assets in the graph.
*
* Example usage:
*
* const allAssetsInGraph = ag.findAssets();
*
* const htmlAssets = ag.findAssets({type: 'Html'});
*
* const localImageAssets = ag.findAssets({
* url: { protocol: 'file:', extension: { $regex: /^(?:png|gif|jpg)$/ }
* });
*
* const orphanedJavaScriptAssets = ag.findAssets(function (asset) {
* return asset.type === 'JavaScript' &&
* ag.findRelations({to: asset}).length === 0;
* });
*
* const textBasedAssetsOnGoogleCom = ag.findAssets({
* isText: true,
* url: {$regex: /^https?:\/\/(?:www\.)google\.com\//}
* });
*
* @param {Object} [queryObj={}] Query to match assets against. Will match all assets if not provided.
* @return {Asset[]} The found assets.
*/
findAssets(queryObj = {}) {
const result = [];
const compiledQuery = compileQuery(queryObj);
for (const asset of this._assets) {
if (compiledQuery(asset)) {
result.push(asset);
}
}
return result;
}
/**
* Query relations in the graph.
*
* Example usage:
*
* const allRelationsInGraph = ag.findRelations();
*
* const allHtmlScriptRelations = ag.findRelations({
* type: 'HtmlScript'
* });
*
* const htmlAnchorsPointingAtLocalImages = ag.findRelations({
* type: 'HtmlAnchor',
* to: {isImage: true, url: {$regex: /^file:/}}
* });
*
* @param {Object} [queryObj={}] Query to match Relations against. Will match all relations if not provided.
* @return {Relation[]} The found relations.
*/
findRelations(queryObj = {}) {
let sourceAssets;
if (queryObj && typeof queryObj.from !== 'undefined') {
if (queryObj.from && queryObj.from.isAsset) {
sourceAssets = [queryObj.from];
} else if (queryObj.from && Array.isArray(queryObj.from)) {
sourceAssets = [];
for (const fromEntry of queryObj.from) {
if (fromEntry.isAsset) {
sourceAssets.push(fromEntry);
} else {
sourceAssets.push(...this.findAssets(fromEntry));
}
}
sourceAssets = _.uniq(sourceAssets);
} else {
sourceAssets = this.findAssets(queryObj.from);
}
} else {
sourceAssets = this._assets;
}
const candidateRelations = [];
for (const sourceAsset of sourceAssets) {
if (sourceAsset.isPopulated) {
candidateRelations.push(...sourceAsset.outgoingRelations);
}
}
const result = [];
const compiledQuery = compileQuery(queryObj);
for (const relation of candidateRelations) {
if (compiledQuery(relation)) {
result.push(relation);
}
}
return result;
}
// Resolve a url while taking the root of the AssetGraph instance into account
resolveUrl(fromUrl, url) {
if (
/^\/(?:[^/]|$)/.test(url) &&
/^file:/.test(fromUrl) &&
/^file:/.test(this.root)
) {
if (this._canonicalRootPath && url.startsWith(this._canonicalRootPath)) {
url = url.substr(this._canonicalRootPath.length);
}
return urlTools.resolveUrl(this.root, url.substr(1));
} else {
return urlTools.resolveUrl(fromUrl, url);
}
}
/**
* @deprecated Use the more generic AssetGraph#buildHref instead
*/
buildRootRelativeUrl(targetUrl, baseUrl) {
return this.buildHref(targetUrl, baseUrl, { hrefType: 'rootRelative' });
}
buildHref(targetUrl, baseUrl, { hrefType, canonical, nonBareRelative } = {}) {
let href;
if (hrefType === 'rootRelative' && !canonical) {
href =
(this._canonicalRootPath || '') +
urlTools.buildRootRelativeUrl(
baseUrl || this.root,
targetUrl,
this.root
);
} else if (hrefType === 'relative' && !canonical) {
href = urlTools.buildRelativeUrl(baseUrl, targetUrl);
if (nonBareRelative && !/^\.\.\/|^\/|^[a-z0-9+]+:/.test(href)) {
href = `./${href}`;
}
} else if (hrefType === 'protocolRelative') {
href = urlTools.buildProtocolRelativeUrl(baseUrl, targetUrl);
} else {
// Absolute or relative/rootRelative in canonical mode
href = targetUrl;
}
if (canonical) {
href = href.replace(this.root, this.canonicalRoot);
}
return href;
}
// Traversal:
eachAssetPreOrder(startAssetOrRelation, relationQueryObj, lambda) {
if (!lambda) {
lambda = relationQueryObj;
relationQueryObj = null;
}
this._traverse(startAssetOrRelation, relationQueryObj, lambda);
}
eachAssetPostOrder(startAssetOrRelation, relationQueryObj, lambda) {
if (!lambda) {
lambda = relationQueryObj;
relationQueryObj = null;
}
this._traverse(startAssetOrRelation, relationQueryObj, null, lambda);
}
_traverse(
startAssetOrRelation,
relationQueryObj,
preOrderLambda,
postOrderLambda
) {
const relationQueryMatcher =
relationQueryObj && compileQuery(relationQueryObj);
let startAsset;
let startRelation;
if (startAssetOrRelation.isRelation) {
startRelation = startAssetOrRelation;
startAsset = startRelation.to;
} else {
// incomingRelation will be undefined when (pre|post)OrderLambda(startAsset) is called
startAsset = startAssetOrRelation;
}
const seenAssets = {};
const assetStack = [];
const traverse = (asset, incomingRelation) => {
if (!seenAssets[asset.id]) {
if (preOrderLambda) {
preOrderLambda(asset, incomingRelation);
}
seenAssets[asset.id] = true;
assetStack.push(asset);
for (const relation of this.findRelations({ from: asset })) {
if (!relationQueryMatcher || relationQueryMatcher(relation)) {
traverse(relation.to, relation);
}
}
const previousAsset = assetStack.pop();
if (postOrderLambda) {
postOrderLambda(previousAsset, incomingRelation);
}
}
};
traverse(startAsset, startRelation);
}
collectAssetsPreOrder(startAssetOrRelation, relationQueryObj) {
const assetsInOrder = [];
this.eachAssetPreOrder(startAssetOrRelation, relationQueryObj, (asset) => {
assetsInOrder.push(asset);
});
return assetsInOrder;
}
collectAssetsPostOrder(startAssetOrRelation, relationQueryObj) {
const assetsInOrder = [];
this.eachAssetPostOrder(startAssetOrRelation, relationQueryObj, (asset) => {
assetsInOrder.push(asset);
});
return assetsInOrder;
}
// Transforms:
_runTransform(transform, cb) {
const startTime = new Date();
const done = (err, result) => {
if (err) {
return cb(err);
}
this.emit('afterTransform', transform, new Date().getTime() - startTime);
cb(null, result);
};
this.emit('beforeTransform', transform);
if (transform.length < 2) {
setImmediate(() => {
let returnValue;
try {
returnValue = transform(this);
} catch (err) {
return done(err);
}
if (returnValue && typeof returnValue.then === 'function') {
returnValue.then((result) => done(null, result), done);
} else {
done(null, returnValue);
}
});
} else {
let callbackCalled = false;
try {
const returnValue = transform(this, (err, result) => {
if (callbackCalled) {
console.warn(
`AssetGraph._runTransform: The transform ${transform.name} called the callback more than once!`
);
} else {
callbackCalled = true;
done(err, result);
}
});
if (returnValue && typeof returnValue.then === 'function') {
setImmediate(() =>
cb(
new Error(
'A transform cannot both take a callback and return a promise'
)
)
);
}
} catch (e) {
setImmediate(() => cb(e));
}
}
return this;
}
_isCompatibleWith(asset, Class) {
if (typeof Class === 'undefined') {
Class = AssetGraph.Asset;
} else if (typeof Class === 'string') {
Class = AssetGraph[Class];
}
return (
asset instanceof Class ||
!asset._type ||
Class.prototype instanceof AssetGraph[asset._type] ||
!!(asset.isImage && Class === AssetGraph.Image) || // Svg is modelled as a subclass of Xml, not Image
!!(asset.isImage && Class === AssetGraph.Font) // Svg can be used as a font as well
);
}
checkIncompatibleTypesForAsset(asset) {
const emittedWarnings = new Set();
const types = asset.incomingRelations
.map((r) => r.targetType)
.filter((t) => t);
const contentType =
asset.location === undefined &&
Object.prototype.hasOwnProperty.call(asset, 'contentType') &&
asset.contentType;
const typeFromContentType =
contentType && AssetGraph.typeByContentType[contentType];
if (
typeFromContentType &&
asset.location === undefined &&
!this._isCompatibleWith(asset, typeFromContentType)
) {
const err = new Error(
`Asset served with a Content-Type of ${contentType}, but used as ${asset.type}`
);
err.asset = asset;
this.warn(err);
}
let commonType;
for (const type of types) {
if (!commonType) {
commonType = type;
} else if (
commonType !== type &&
!AssetGraph[commonType].prototype[`is${type}`] &&
!AssetGraph[type].prototype[`is${commonType}`]
) {
warnIncompatibleTypes([commonType, type], asset, emittedWarnings);
commonType = undefined;
break;
}
}
if (commonType) {
if (
asset._inferredType &&
!AssetGraph[commonType].prototype[`is${asset._inferredType}`] &&
!AssetGraph[asset._inferredType].prototype[`is${commonType}`]
) {
warnIncompatibleTypes(
[asset._inferredType, commonType],
asset,
emittedWarnings
);
} else {
asset._inferredType = commonType;
}
}
if (contentType === 'text/plain' && commonType && commonType !== 'Text') {
// Don't allow an isText asset to pass as compatible with explicit text/plain
const err = new Error(
`Asset served with a Content-Type of ${contentType}, but used as ${commonType}`
);
err.asset = asset;
this.warn(err);
}
}
}
module.exports = AssetGraph;
AssetGraph.typeByExtension = AssetGraph.prototype.typeByExtension = {};
AssetGraph.typeByContentType = AssetGraph.prototype.typeByContentType = {};
// FIXME: Add this capability to the individual assets
AssetGraph.typeByContentType['text/javascript'] = 'JavaScript';
AssetGraph.typeByContentType['application/x-font-woff'] = 'Woff';
AssetGraph.lookupContentType = AssetGraph.prototype.lookupContentType = (
contentType
) => {
if (contentType) {
// Trim whitespace and semicolon suffixes such as ;charset=...
contentType = contentType.match(/^\s*([^;\s]*)(?:;|\s|$)/)[1].toLowerCase(); // Will always match
if (AssetGraph.typeByContentType[contentType]) {
return AssetGraph.typeByContentType[contentType];
} else if (/\+xml$/i.test(contentType)) {
const contentTypeWithoutXmlSuffix = contentType.replace(/\+xml$/i, '');
return AssetGraph.typeByContentType[contentTypeWithoutXmlSuffix] || 'Xml';
} else if (AssetGraph.typeByContentType[`${contentType}+xml`]) {
return AssetGraph.typeByContentType[`${contentType}+xml`];
} else if (/^text\//i.test(contentType)) {
return 'Text';
}
}
};
// Add AssetGraph helper methods that implicitly create a new TransformQueue:
for (const methodName of ['if', 'queue']) {
AssetGraph.prototype[methodName] = function (...args) {
// ...
const transformQueue = new TransformQueue(this);
return transformQueue[methodName].apply(transformQueue, args);
};
}
AssetGraph.prototype.if_ = AssetGraph.prototype.if;
AssetGraph.transforms = {};
AssetGraph.registerTransform = (fileNameOrFunction, name) => {
if (typeof fileNameOrFunction === 'function') {
name = name || fileNameOrFunction.name;
AssetGraph.transforms[name] = fileNameOrFunction;
} else {
// File name
name = name || pathModule.basename(fileNameOrFunction, '.js');
fileNameOrFunction = pathModule.resolve(process.cwd(), fileNameOrFunction); // Absolutify if not already absolute
AssetGraph.transforms.__defineGetter__(name, () =>
require(fileNameOrFunction)
);
}
TransformQueue.prototype[name] = function (...args) {
// ...
if (
!this.conditions.length ||
this.conditions[this.conditions.length - 1]
) {
this.transforms.push(AssetGraph.transforms[name].apply(this, args));
}
return this;
};
// Make assetGraph.<transformName>(options) a shorthand for creating a new TransformQueue:
AssetGraph.prototype[name] = function (...args) {
// ...
const transformQueue = new TransformQueue(this);
return transformQueue[name].apply(transformQueue, args);
};
};
/**
* Register a new [Asset]{@link Asset} type in AssetGraph
*
* @static
* @param {Function} Constructor An Asset constructor
* @param {String} type a unique type for the asset
*/
AssetGraph.registerAsset = (Constructor, type) => {
type = type || Constructor.name;
const prototype = Constructor.prototype;
let publicType = type;
if (type === 'Asset') {
publicType = undefined;
} else {
prototype._type = type;
}
AssetGraph[type] = AssetGraph.prototype[type] = Constructor;
Constructor.relations = new Set();
Constructor.prototype[`is${type}`] = true;
if (
prototype.contentType &&
(!Object.prototype.hasOwnProperty.call(
prototype,
'notDefaultForContentType'
) ||
!prototype.notDefaultForContentType)
) {
if (AssetGraph.typeByContentType[prototype.contentType]) {
console.warn(
`${type}: Redefinition of Content-Type ${prototype.contentType}`
);
console.trace();
}
AssetGraph.typeByContentType[prototype.contentType] = publicType;
}
if (prototype.supportedExtensions) {
for (const supportedExtension of prototype.supportedExtensions) {
if (AssetGraph.typeByExtension[supportedExtension]) {
console.warn(
`${type}: Redefinition of ${supportedExtension} extension`
);
console.trace();
}
AssetGraph.typeByExtension[supportedExtension] = publicType;
}
}
};
/**
* Register a new [Relation]{@link Relation} type in AssetGraph
*
* @static
* @param {Function | String} fileNameOrConstructor A Relation constructor or a file name that exports one
* @param {String} type A unique type for the relation
*/
AssetGraph.registerRelation = (fileNameOrConstructor, type) => {
if (typeof fileNameOrConstructor === 'function') {
type = type || fileNameOrConstructor.name;
fileNameOrConstructor.prototype.type = type;
AssetGraph[type] = AssetGraph.prototype[type] = fileNameOrConstructor;
} else {
const fileNameRegex =
os.platform() === 'win32' ? /\\([^\\]+)\.js$/ : /\/([^/]+)\.js$/;
// Assume file name
type = type || fileNameOrConstructor.match(fileNameRegex)[1];
const getter = () => {
const Constructor = require(fileNameOrConstructor);
Constructor.prototype.type = type;
return Constructor;
};
AssetGraph.__defineGetter__(type, getter);
AssetGraph.prototype.__defineGetter__(type, getter);
}
};
for (const fileName of fs.readdirSync(
pathModule.resolve(__dirname, 'transforms')
)) {
AssetGraph.registerTransform(
pathModule.resolve(__dirname, 'transforms', fileName)
);
}
for (const fileName of fs.readdirSync(
pathModule.resolve(__dirname, 'assets')
)) {
if (/\.js$/.test(fileName) && fileName !== 'index.js') {
AssetGraph.registerAsset(
require(pathModule.resolve(__dirname, 'assets', fileName))
);
}
}
// Register relations
for (const fileName of glob.sync(
pathModule.resolve(__dirname, 'relations', '*', '*.js')
)) {
const dirName = pathModule.dirname(fileName);
const assetType = pathModule.basename(dirName);
const Relation = require(fileName);
AssetGraph[assetType].registerRelation(Relation);
}