UNPKG

@modular-css/rollup

Version:

Add modular-css support to rollup

366 lines (280 loc) 11.7 kB
"use strict"; const path = require("path"); const utils = require("@rollup/pluginutils"); const Processor = require("@modular-css/processor"); const output = require("@modular-css/processor/lib/output.js"); const relative = require("@modular-css/processor/lib/relative.js"); const { transform } = require("@modular-css/css-to-js"); const DEFAULT_EXT = ".css"; const { isFile, } = Processor; // sourcemaps for css-to-js don't make much sense, so always return nothing // https://github.com/rollup/rollup/wiki/Plugins#conventions const emptyMappings = { mappings : "", }; const DEFAULTS = { dev : false, empties : false, json : false, map : false, meta : false, styleExport : false, verbose : false, namedExports : { rewriteInvalid : true, warn : true, }, // Regexp to work around https://github.com/rollup/rollup-pluginutils/issues/39 include : /\.css$/i, }; module.exports = ( /* istanbul ignore next: too painful to test */ opts = {} ) => { const options = { __proto__ : null, ...DEFAULTS, ...opts, }; const { processor = new Processor(options), } = options; const filter = utils.createFilter(options.include, options.exclude); // eslint-disable-next-line no-console, no-empty-function -- logging const log = options.verbose ? console.log.bind(console, "[rollup]") : () => { }; // istanbul ignore if: too hard to test this w/ defaults if(typeof options.map === "undefined") { // Sourcemaps don't make much sense in styleExport mode // But default to true otherwise options.map = !options.styleExport; } const { graph } = processor; return { name : "@modular-css/rollup", buildStart() { log("build start"); if(!options.namedExports) { this.warn("@modular-css/rollup doesn't allow namedExports to be disabled"); } // done lifecycle won't ever be called on per-component styles since // it only happens at bundle compilation time // Need to do this on buildStart so it has access to this.warn() o_O if(options.styleExport && options.done) { this.warn( `Any plugins defined during the "done" lifecycle won't run when "styleExport" is set!` ); } // Watch any files already in the procesor Object.keys(processor.files).forEach((file) => this.addWatchFile(file)); }, watchChange(file) { if(!processor.has(file)) { return; } log("file changed", file); // TODO: should the file be removed if it's gone? processor.invalidate(file); }, async transform(src, id) { if(!filter(id)) { return null; } log("transform", id); try { await processor.string(id, src); } catch(e) { // Replace the default message with the much more verbose one e.message = e.toString(); return this.error(e); } const { code, namedExports, dependencies, warnings } = transform(id, processor, opts); warnings.forEach((warning) => { this.warn(warning); }); dependencies.forEach((depKey) => { if(!isFile(depKey)) { return; } // Watch all the CSS files this file depends on this.addWatchFile(graph.getNodeData(depKey).file); }); // Return JS representation to rollup return { code, map : emptyMappings, // Disable tree-shaking for CSS modules w/o any classes/values to export // to make sure they're included in the bundle moduleSideEffects : namedExports.length || "no-treeshake", }; }, // eslint-disable-next-line max-statements, complexity -- too much state to extract async generateBundle(outputOptions, bundle) { // styleExport disables all output file generation if(options.styleExport) { return; } const { file, dir, // TODO: why doesn't rollup provide this? :( assetFileNames = "assets/[name]-[hash][extname]", } = outputOptions; const chunks = new Map(); const used = new Set(); // Determine the correct to option for PostCSS by doing a bit of a dance const to = (!file && !dir) ? path.join(processor.options.cwd, assetFileNames) : path.join( dir ? dir : path.dirname(file), assetFileNames ); // Walk bundle, determine CSS output files // TODO: remove any files that only export @values but no classes? Object.keys(bundle).forEach((entry) => { const { type, modules, name } = bundle[entry]; /* istanbul ignore if */ if(type === "asset") { return; } const deps = Object.keys(modules).reduce((acc, f) => { if(processor.has(f)) { const css = processor.normalize(f); used.add(css); acc.push(css); } return acc; }, []); if(!deps.length) { return; } chunks.set(entry, { deps, name }); }); // Add any bare CSS files to be output processor.fileDependencies().forEach((css) => { if(used.has(css)) { return; } const { name } = path.parse(css); chunks.set(name, { deps : [ css ], name }); }); // If assets are being hashed then the automatic annotation has to be disabled // because it won't include the hashed value and will lead to badness let mapOpt = options.map; if(assetFileNames.includes("[hash]") && typeof mapOpt === "object") { mapOpt = { __proto__ : null, ...mapOpt, annotation : false, }; } // Track specified name -> output name for writing out metadata later const names = new Map(); // Track chunks that don't actually need to be output const duds = new Set(); for(const [ entry, { deps, name }] of chunks) { // eslint-disable-next-line no-await-in-loop -- has to happen in order const result = await processor.output({ // Can't use this.getAssetFileName() here, because the source hasn't been set yet // Have to do our best to come up with a valid final location though... to : to.replace(/\[(name|extname)\]/g, (match, field) => (field === "name" ? name : DEFAULT_EXT) ), map : mapOpt, files : deps, }); // Don't output empty files if empties is falsey if(!options.empties && !result.css.length) { duds.add(entry); continue; } const id = this.emitFile({ type : "asset", name : `${name}${DEFAULT_EXT}`, source : result.css, }); // Save off the final name of this asset for later use const dest = this.getFileName(id); names.set(entry, dest); log("css output", dest); if(result.map) { // Make sure to use the rollup name as the base, otherwise it won't // automatically handle duplicate names correctly const fileName = dest.replace(DEFAULT_EXT, `${DEFAULT_EXT}.map`); log("map output", fileName); this.emitFile({ type : "asset", source : result.map.toString(), // Use fileName instead of name because this has to follow the parent // file naming and can't be double-hashed fileName, }); // Had to re-add the map annotation to the end of the source files // if the filename had a hash, since we stripped it out up above if(assetFileNames.includes("hash")) { bundle[dest].source += `\n/*# sourceMappingURL=${path.basename(fileName)} */`; } } } if(options.json) { const files = Object.keys(processor.files); // Ensure file order is consistent files.sort(); // Wait to ensure that all files have completed processing await Promise.all( files.map((id) => processor.files[id].result) ); const json = Object.create(null); files.forEach((id) => { json[relative(processor.options.cwd, id)] = { // @values ...output.values(processor.files[id].values), // classes ...output.fileCompositions(processor.files[id], processor, { joined : true }), }; }); const source = JSON.stringify(json, null, 4); if(typeof options.json === "string") { log("json output", options.json); this.emitFile({ type : "asset", fileName : options.json, source, }); } else { log("json output", "exports.json"); this.emitFile({ type : "asset", name : "exports.json", source, }); } } // Always attach meta info to bundle chunks const meta = {}; for(const [ entry ] of chunks) { const chunk = bundle[entry]; if(!chunk) { continue; } // Attach info about this asset to the bundle const { assets = [] } = chunk; assets.push(names.get(entry)); chunk.assets = assets; meta[entry] = { assets, }; } if(options.meta) { const dest = typeof options.meta === "string" ? options.meta : "metadata.json"; log("metadata output", dest); this.emitFile({ type : "asset", source : JSON.stringify(meta, null, 4), name : dest, }); } }, }; };