UNPKG

makestatic-inline-css

Version:

Transforms external stylesheets to inline styles

291 lines (250 loc) 9.12 kB
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