makestatic-inline-css
Version:
Transforms external stylesheets to inline styles
291 lines (250 loc) • 9.12 kB
JavaScript
const url = require('url')
const path = require('path')
/**
* For each HTML file inline transform stylesheets (`link(rel="stylesheet")`)
* into inline `style` elements.
* Finds `link` elements in the HTML AST and converts them into `style`
* elements with the content of the external stylesheet inlined.
*
* When the `prune` option is enabled this implementation will remove style
* rules whose selectors do not match elements in the document.
*
* Note that the `prune` option whilst in use and appears to be working should
* be considered experimental.
*
* @class TransformInlineCss
*/
class TransformInlineCss {
/**
* Initialize the list of matched resources used in the `after` hook to
* delete matched resources when the `remove` option is set.
*
* @function before
* @member TransformInlineCss
*/
before () {
this.matched = []
}
/**
* For each file with an HTML AST find all `link` elements that have the
* `rel` attribute set to `stylesheet` and have the `href` attribute set.
*
* If the `href` attribute does not contain a protocol it is considered to
* reference a stylesheet that is available in the compilation asset list,
* in which case the stylesheet content from the referenced file is added as
* a text node of a `style` element and the `link` element is removed.
*
* Supports absolute `href` attributes like `/style.css` and paths relative
* to the HTML document such as `../style.css`.
*
* Multiple external stylesheets that are being inlined will be concatenated
* into a single `style` element.
*
* If the `link` element has a `media` attribute it must match the pattern
* specified using the `media` option, by default this is configured to
* match stylesheets that have a media query including one of:
*
* + `all`
* + `screen`
* + `handheld`
*
* If a referenced stylesheet cannot be found in the compilation assets a
* warning is logged.
*
* If watch mode is enabled for the compiler the `remove` option is disabled
* otherwise repeat compiles will not find the stylesheet to inline as it has
* been removed from the compilation assets.
*
* @function sources
* @member TransformInlineCss
*
* @param {Object} file the file being processed.
* @param {Object} context the processing context.
* @param {Object} options the plugin options.
*
* @option {Boolean=false} remove delete matched resources.
* @option {Boolean=false} prune remove rules that do not match the dom.
* @option {RegExp} [media] pattern used to test the media attribute.
*/
sources (file, context, options = {}) {
const log = context.log
const ast = file.ast.html
this.remove = options.remove !== undefined ? options.remove : false
this.prune = options.prune !== undefined ? options.prune : false
this.media = options.media || /(screen|handheld|all)/
// never remove in watch mode otherwise repeat compiles do not find
// the file
if (context.options.watch) {
this.remove = false
}
// no parsed ast - nothing to be done
if (!ast) {
return
}
const adapter = ast.adapter
let first
function rewrite (el, content, index) {
if (first) {
let value = first.childNodes[0].value
/* istanbul ignore else: have buffers in test env */
if (Buffer.isBuffer(value) && Buffer.isBuffer(content)) {
value = Buffer.concat(
[value, new Buffer('\n'), content],
value.length + 1 + content.length)
// assume string buffer
} else {
value = value + '\n' + content
}
// rewrite value of first style element
first.childNodes[0].value = value
// remove link element that has been concatenated
el.parentNode.childNodes.splice(index, 1)
return
}
// rewrite <link> to <style>
el.tagName = 'style'
// clear <link> tag attributes
el.attrs = []
let txt = adapter.createTextNode(content)
txt.parentNode = el
// inject css content as child text node
el.childNodes = [txt]
first = el
ast.dirty = true
}
ast.walk((el) => {
// find references to external stylesheets
return el.tagName === 'link' &&
adapter.getAttribute(el, 'rel', 'stylesheet')
}, (el, index) => {
// get the href to the stylesheet
const href = adapter.getAttribute(el, 'href')
const media = adapter.getAttribute(el, 'media')
if (media && !this.media.test(media)) {
return
}
if (href) {
const uri = href.value
// got a protocol in the `href` attribute, cannot process
if (/^\w+:\/\//.test(uri)) {
return
}
if (uri) {
const absolute = /^\//
let key
let asset
// got an absolute path to the css file
if (absolute.test(uri)) {
key = uri.replace(absolute, '')
asset = context.list.get(key)
} else {
// get the parent directory for the HTML file
let dir = path.dirname(file.name)
if (!path.isAbsolute(dir)) {
dir = path.join(process.cwd(), dir)
}
// get the relative portion to the parent directory
let rel = file.relative(context.config.context, false, dir)
// parse an absolute url to the stylesheet using a mock domain
let abs = url.resolve('http://example.com' + rel, uri)
// extract the pathname so we have the key for the asset
key = url.parse(abs).pathname.replace(/^\//, '')
// retrieve the asset
asset = context.list.get(key)
}
// got webpack compilation asset
if (asset) {
// Inline data plugin can rewrite CSS ast
if (asset.ast.css && asset.ast.css.dirty) {
asset.content = asset.ast.css.serialize()
}
let content = asset.content
if (this.prune && file.ast.html && asset.ast.css) {
log.info('[inline-css] prune file %s', file.path)
// NOTE: we must parse the css ast fresh each time otherwise
// NOTE: it refers to the external stylesheet file and pruned
// NOTE: selectors carry across pages which is very bad
content = this.getDocumentCss(
context,
file.ast.html,
asset.ast.css.clone(asset.content).parse())
}
//console.dir(content.toString())
this.matched.push(key)
//console.log('rewrite with content: ' + content)
rewrite(el, content, index)
} else {
log.warn('[inline-css] cannot inline css, missing file %s', key)
}
}
}
})
}
getDocumentCss (context, html, styles) {
const log = context.log
function prune (nodes, list) {
let element, node, selector
for (let i = 0; i < nodes.length; i++) {
node = nodes[i]
if (node.type === 'rule' && node.selector) {
if (node.selector === '::-moz-selection' ||
node.selector === '::selection') {
continue
}
selector = node.selector
// prune unsupported pseudo selectors
selector = selector.replace(/::?hover/g, '')
selector = selector.replace(/::?before/g, '')
selector = selector.replace(/::?after/g, '')
selector = selector.replace(/::?first-letter/g, '')
// collapse whitespace for legibility
selector = selector.replace('\n', '')
node.selector = node.selector.replace('\n', '')
element = html.querySelector(selector)
if (!element) {
nodes.splice(i, 1)
log.info(
'[inline-css] prune rule %s (%s)', selector, node.selector)
list.push(node)
i--
}
// media query at rules
} else if (node.type === 'atrule' && node.name === 'media' &&
node.nodes && node.nodes.length) {
prune(node.nodes, list)
}
}
}
styles.modify((ast) => {
const list = []
prune(ast.nodes, list)
return list.length
})
return styles.serialize()
}
/**
* Deletes matched resources from the compilation assets when the
* `remove` option has been enabled.
*
* @function after
* @member TransformInlineCss
* @param {Object} context the procesing context.
*/
after (context) {
if (this.remove) {
// make values unique, cannot remove a file twice ;)
this.matched = this.matched.filter((val, index, arr) => {
if (arr.indexOf(val) === index) {
return val
}
})
this.matched.forEach((key) => {
context.list.remove(key)
})
}
}
static get test () {
return /\.(html|sgr)$/
}
}
module.exports = TransformInlineCss