Skip to content

Commit

Permalink
feat(utils): Update CSSOM for nested @import computation (dequelabs#1339
Browse files Browse the repository at this point in the history
)
  • Loading branch information
jeeyyy committed May 14, 2019
1 parent 3502fdc commit a4e177b
Show file tree
Hide file tree
Showing 36 changed files with 1,778 additions and 1,247 deletions.
17 changes: 8 additions & 9 deletions lib/core/base/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,21 +436,20 @@ Audit.prototype.run = function(context, options, resolve, reject) {
const preloaderQueue = axe.utils.queue();
// defer preload if preload dependent rules exist
if (runLaterRules.length) {
preloaderQueue.defer((res, rej) => {
preloaderQueue.defer(resolve => {
// handle both success and fail of preload
// and resolve, to allow to run all checks
axe.utils
.preload(options)
.then(preloadResults => {
// pluck first item in results (because it is a queue - meh!) and resolve
const assets = preloadResults[0];
res(assets);
})
.then(assets => resolve(assets))
.catch(err => {
// resolve as undefined, to allow rule.run to continue
/**
* Note:
* we do not reject, to allow other (non-preload) rules to `run`
* -> instead we resolve as `undefined`
*/
console.warn(`Couldn't load preload assets: `, err);
const assets = undefined;
res(assets);
resolve(undefined);
});
});
}
Expand Down
12 changes: 10 additions & 2 deletions lib/core/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,16 @@
resultGroups: [],
resultGroupMap: {},
impact: Object.freeze(['minor', 'moderate', 'serious', 'critical']),
preloadAssets: Object.freeze(['cssom']), // overtime this array will grow with other preload asset types, this constant is to verify if a requested preload type by the user via the configuration is supported by axe.
preloadAssetsTimeout: 10000
preload: Object.freeze({
/**
* array of supported & preload(able) asset types.
*/
assets: ['cssom'],
/**
* timeout value when resolving preload(able) assets
*/
timeout: 10000
})
};

definitions.forEach(function(definition) {
Expand Down
51 changes: 51 additions & 0 deletions lib/core/utils/get-stylesheet-factory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Function which converts given text to `CSSStyleSheet`
* - used in `CSSOM` computation.
* - factory (closure) function, initialized with `document.implementation.createHTMLDocument()`, which uses DOM API for creating `style` elements.
*
* @method axe.utils.getStyleSheetFactory
* @memberof axe.utils
* @param {Object} dynamicDoc `document.implementation.createHTMLDocument()
* @param {Object} options an object with properties to construct stylesheet
* @property {String} options.data text content of the stylesheet
* @property {Boolean} options.isCrossOrigin flag to notify if the resource was fetched from the network
* @property {String} options.shadowId (Optional) shadowId if shadowDOM
* @property {Object} options.root implementation document to create style elements
* @property {String} options.priority a number indicating the loaded priority of CSS, to denote specificity of styles contained in the sheet.
* @returns {Function}
*/
axe.utils.getStyleSheetFactory = function getStyleSheetFactory(dynamicDoc) {
if (!dynamicDoc) {
throw new Error(
'axe.utils.getStyleSheetFactory should be invoked with an argument'
);
}

return options => {
const {
data,
isCrossOrigin = false,
shadowId,
root,
priority,
isLink = false
} = options;
const style = dynamicDoc.createElement('style');
if (isLink) {
// as creating a stylesheet as link will need to be awaited
// till `onload`, it is wise to convert link href to @import statement
const text = dynamicDoc.createTextNode(`@import "${data.href}"`);
style.appendChild(text);
} else {
style.appendChild(dynamicDoc.createTextNode(data));
}
dynamicDoc.head.appendChild(style);
return {
sheet: style.sheet,
isCrossOrigin,
shadowId,
root,
priority
};
};
};
53 changes: 53 additions & 0 deletions lib/core/utils/parse-crossorigin-stylesheet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Parse cross-origin stylesheets
*
* @method parseCrossOriginStylesheet
* @memberof axe.utils
* @param {String} url url from which to fetch stylesheet
* @param {Object} options options object from `axe.utils.parseStylesheet`
* @param {Array<Number>} priority sheet priority
* @param {Array<String>} importedUrls urls of already imported stylesheets
* @param {Boolean} isCrossOrigin boolean denoting if a stylesheet is `cross-origin`
* @returns {Promise}
*/
axe.utils.parseCrossOriginStylesheet = function parseCrossOriginStylesheet(
url,
options,
priority,
importedUrls,
isCrossOrigin
) {
const axiosOptions = {
method: 'get',
url
};

/**
* Add `url` to `importedUrls`
*/
importedUrls.push(url);

/**
* Fetch `cross-origin stylesheet` via axios
*/
return axe.imports.axios(axiosOptions).then(({ data }) => {
const result = options.convertDataToStylesheet({
data,
isCrossOrigin,
priority,
root: options.rootNode,
shadowId: options.shadowId
});

/**
* Parse resolved stylesheet further for any `@import` styles
*/
return axe.utils.parseStylesheet(
result.sheet,
options,
priority,
importedUrls,
result.isCrossOrigin
);
});
};
96 changes: 96 additions & 0 deletions lib/core/utils/parse-sameorigin-stylesheet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Parse non cross-origin stylesheets
*
* @method parseSameOriginStylesheet
* @memberof axe.utils
* @param {Object} sheet CSSStylesheet object
* @param {Object} options options object from `axe.utils.parseStylesheet`
* @param {Array<Number>} priority sheet priority
* @param {Array<String>} importedUrls urls of already imported stylesheets
* @param {Boolean} isCrossOrigin boolean denoting if a stylesheet is `cross-origin`
* @returns {Promise}
*/
axe.utils.parseSameOriginStylesheet = function parseSameOriginStylesheet(
sheet,
options,
priority,
importedUrls,
isCrossOrigin = false
) {
const rules = Array.from(sheet.cssRules);

if (!rules) {
return Promise.resolve();
}

/**
* reference -> https://developer.mozilla.org/en-US/docs/Web/API/CSSRule#Type_constants
*/
const cssImportRules = rules.filter(r => r.type === 3); // type === 3 -> CSSRule.IMPORT_RULE

/**
* when no `@import` rules in given sheet -> resolve the current `sheet` & exit
*/
if (!cssImportRules.length) {
// exit
return Promise.resolve({
isCrossOrigin,
priority,
root: options.rootNode,
shadowId: options.shadowId,
sheet
});
}

/**
* filter rules that are not already fetched
*/
const cssImportUrlsNotAlreadyImported = cssImportRules
// ensure rule has a href
.filter(rule => rule.href)
// extract href from object
.map(rule => rule.href)
// only href that are not already imported
.filter(url => !importedUrls.includes(url));

/**
* iterate `@import` rules and fetch styles
*/
const promises = cssImportUrlsNotAlreadyImported.map(
(importUrl, cssRuleIndex) => {
const newPriority = [...priority, cssRuleIndex];
const isCrossOriginRequest = /^https?:\/\/|^\/\//i.test(importUrl);

return axe.utils.parseCrossOriginStylesheet(
importUrl,
options,
newPriority,
importedUrls,
isCrossOriginRequest
);
}
);

const nonImportCSSRules = rules.filter(r => r.type !== 3);

// no further rules to process in this sheet
if (!nonImportCSSRules.length) {
return Promise.all(promises);
}

// convert all `nonImportCSSRules` style rules into `text` and chain

promises.push(
Promise.resolve(
options.convertDataToStylesheet({
data: nonImportCSSRules.map(rule => rule.cssText).join(),
isCrossOrigin,
priority,
root: options.rootNode,
shadowId: options.shadowId
})
)
);

return Promise.all(promises);
};
70 changes: 70 additions & 0 deletions lib/core/utils/parse-stylesheet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Parse a given stylesheet
*
* @method parseStylesheet
* @memberof axe.utils
* @param {Object} sheet stylesheet to parse
* @param {Object} options configuration options object from `axe.utils.parseStylesheets`
* @param {Array<Number>} priority priority of stylesheet
* @param {Array<String>} importedUrls list of resolved `@import` urls
* @param {Boolean} isCrossOrigin boolean denoting if a stylesheet is `cross-origin`, passed for re-parsing `cross-origin` sheets
* @returns {Promise}
*/
axe.utils.parseStylesheet = function parseStylesheet(
sheet,
options,
priority,
importedUrls,
isCrossOrigin = false
) {
const isSameOrigin = isSameOriginStylesheet(sheet);
if (isSameOrigin) {
/**
* resolve `same-origin` stylesheet
*/
return axe.utils.parseSameOriginStylesheet(
sheet,
options,
priority,
importedUrls,
isCrossOrigin
);
}

/**
* resolve `cross-origin` stylesheet
*/
return axe.utils.parseCrossOriginStylesheet(
sheet.href,
options,
priority,
importedUrls,
true // -> isCrossOrigin
);
};

/**
* Check if a given stylesheet is from the `same-origin`
* Note:
* `sheet.cssRules` throws an error on `cross-origin` stylesheets
*
* @param {Object} sheet CSS stylesheet
* @returns {Boolean}
*/
function isSameOriginStylesheet(sheet) {
try {
/*eslint no-unused-vars: 0*/
const rules = sheet.cssRules;

/**
* Safari, does not throw an error when accessing cssRules property,
*/
if (!rules && sheet.href) {
return false;
}

return true;
} catch (e) {
return false;
}
}
Loading

0 comments on commit a4e177b

Please sign in to comment.