UNPKG

@modular-css/processor

Version:

A streamlined reinterpretation of CSS Modules

684 lines (509 loc) 19.2 kB
"use strict"; const path = require("path"); const Graph = require("dependency-graph").DepGraph; const postcss = require("postcss"); const slug = require("unique-slug"); const postcssUrl = require("postcss-url"); const { compositions, fileCompositions } = require("./lib/output.js"); const relative = require("./lib/relative.js"); const tiered = require("./lib/graph-tiers.js"); const normalize = require("./lib/normalize.js"); const { resolvers } = require("./lib/resolve.js"); const pluginAtComposes = require("./plugins/at-composes.js"); const pluginComposition = require("./plugins/composition.js"); const pluginExternals = require("./plugins/externals.js"); const pluginGraphNodes = require("./plugins/before/graph-nodes.js"); const pluginScoping = require("./plugins/scoping.js"); const pluginValuesImport = require("./plugins/values-import.js"); const pluginValuesLocal = require("./plugins/before/values-local.js"); const pluginValuesReplace = require("./plugins/values-replace.js"); const keys = require("./lib/keys.js"); const { selectorKey, fileKey, valueKey, filterByPrefix, FILE_PREFIX, } = keys; let fs; const noop = () => true; const defaultLoadFile = (id) => { if(!fs) { const name = "fs"; fs = require(name); } return fs.readFileSync(id, "utf8"); }; const params = (processor, args) => { const { _options } = processor; return { __proto__ : null, ..._options, ..._options.postcss, from : null, processor, ...args, }; }; const DEFAULTS = { cwd : false, map : false, dupewarn : true, loadFile : defaultLoadFile, postcss : {}, resolvers : [], rewrite : true, verbose : false, exportGlobals : true, }; class Processor { constructor(opts = {}) { const options = { __proto__ : null, ...DEFAULTS, ...opts, cwd : opts.cwd || process.cwd(), }; this._options = options; if(!path.isAbsolute(options.cwd)) { options.cwd = path.resolve(options.cwd); } if(typeof options.namer === "string") { options.namer = require(options.namer)(); } if(typeof options.namer !== "function") { options.namer = (file, selector) => `mc${slug(relative(options.cwd, file))}_${selector}`; } // eslint-disable-next-line no-console, no-empty-function -- logging! this._log = options.verbose ? console.log.bind(console, "[processor]") : () => {}; this._loadFile = options.loadFile; this._resolve = resolvers(options.resolvers); this._normalize = normalize.bind(null, this._options.cwd); this._files = Object.create(null); this._graph = new Graph(); this._ids = new Map(); this._warnings = []; this._before = postcss([ ...(options.before || []), pluginValuesLocal, pluginValuesReplace, pluginGraphNodes, ]); this._process = postcss([ pluginAtComposes, pluginValuesImport, pluginValuesReplace, pluginScoping, pluginExternals, pluginComposition, ...(options.processing || []), ]); this._after = postcss(options.after || [ noop ]); // Add postcss-url to the afters if requested if(options.rewrite) { this._after.use(postcssUrl(options.rewrite)); } this._done = postcss(options.done || [ noop ]); } // Add a file on disk to the dependency graph async file(file) { const id = this._normalize(file); this._log("file()", id); const text = await this._loadFile(id); return this._add(id, text); } // Add a file by name + contents to the dependency graph string(file, text) { const id = this._normalize(file); this._log("string()", id); return this._add(id, text); } // Add an existing postcss Root object by name root(file, root) { const id = this._normalize(file); this._log("root()", id); return this._add(id, root); } // Remove a file from the dependency graph remove(input) { // Only want files actually in the array const files = Array.isArray(input) ? input : [ input ]; files.forEach((file) => { const normalized = this._normalize(file); const key = fileKey(normalized); if(!this._graph.hasNode(key)) { return; } this._graph.removeNode(key); delete this._files[file]; this._log("remove()", normalized); }); return files; } // Return the corrected-path version of the file normalize(file) { return this._normalize(file); } // Resolve a file from a src using the configured resolvers resolve(src, file) { return this._resolve(src, file); } // Check if a file exists in the currently-processed set has(input) { const file = this._normalize(input); return file in this._files; } // Mark a file and everything that depends on it as invalid invalidate(input) { if(!input) { throw new Error("invalidate() requires a file argument"); } // Only want files actually in the array const normalized = this._normalize(input); const key = fileKey(normalized); if(!this._graph.hasNode(key)) { throw new Error(`Unknown file: ${normalized}`); } // Mark the file & all files that depend on it invalid [ ...filterByPrefix(FILE_PREFIX, this._graph.dependantsOf(key)), normalized ].forEach((file) => { this._files[file].valid = false; this._ids.delete(file.toLowerCase()); }); // Mark all selectors in the file invalid this._graph.dependenciesOf(key).forEach((dep) => { const data = this._graph.getNodeData(dep); if(data.file !== normalized) { return; } data.valid = false; }); this._log("invalidate()", normalized); } // Get the dependency order for a file or the entire tree fileDependencies(file) { if(!file) { return filterByPrefix(FILE_PREFIX, this._graph.overallOrder()); } const normalized = this._normalize(file); const key = fileKey(normalized); if(!this._graph.hasNode(key)) { throw new Error(`Unknown file: ${normalized}`); } return filterByPrefix(FILE_PREFIX, this._graph.dependenciesOf(key)); } // Get the file dependents for a specific file fileDependents(file) { if(!file) { throw new Error("fileDepenendents() must be called with a file"); } const normalized = this._normalize(file); const key = fileKey(normalized); if(!this._graph.hasNode(key)) { throw new Error(`Unknown file: ${normalized}`); } return filterByPrefix(FILE_PREFIX, this._graph.dependantsOf(key)); } // Get the ultimate output for specific files or the entire tree async output(args = false) { const { to } = args; let { files } = args; if(!Array.isArray(files)) { files = filterByPrefix(FILE_PREFIX, tiered(this._graph)); } // Throw normalized values into a Set to remove dupes files = new Set(files.map(this._normalize)); // Then turn it back into array because the iteration story is better files = [ ...files.values() ]; // Verify that all requested files have been fully processed & succeeded // See // - https://github.com/tivac/modular-css/issues/248 // - https://github.com/tivac/modular-css/issues/324 await Promise.all(files.map((file) => { if(!this._files[file]) { throw new Error(`Unknown file requested: ${file}`); } return this._files[file].result; })); // Rewrite relative URLs before adding // Have to do this every time because target file might be different! const results = []; for(const dep of files) { this._log("_after()", dep); // eslint-disable-next-line no-await-in-loop -- we're ok await this._files[dep].walked; // eslint-disable-next-line no-await-in-loop -- we got this const result = await this._after.process( // NOTE: the call to .clone() is really important here, otherwise this call // modifies the .result root itself and you process URLs multiple times // See https://github.com/tivac/modular-css/issues/35 this._files[dep].result.root.clone(), params(this, { from : dep, to, }) ); results.push(result); } // Clone the first result if available to get valid source information const root = results.length ? results[0].root.clone() : postcss.root(); // Then destroy all its children before adding new ones root.removeAll(); results.forEach((result) => { result.warnings().forEach((warning) => this._warnings.push(warning)); // Add file path comment const comment = postcss.comment({ text : relative(this._options.cwd, result.opts.from), // Add a bogus-ish source property so postcss won't make weird-looking // source-maps that break the visualizer // // https://github.com/postcss/postcss/releases/tag/5.1.0 // https://github.com/postcss/postcss/pull/761 // https://github.com/tivac/modular-css/pull/157 // source : { __proto__ : null, ...result.root.source, end : result.root.source.start, }, }); root.append([ comment, ...result.root.nodes ]); const idx = root.index(comment); // Need to manually insert a newline after the comment, but can only // do that via whatever comes after it for some reason? // I'm not clear why comment nodes lack a `.raws.after` property // // https://github.com/postcss/postcss/issues/44 if(root.nodes[idx + 1]) { root.nodes[idx + 1].raws.before = "\n"; } }); const result = await this._done.process( root, params(this, args) ); result.warnings().forEach((warning) => this._warnings.push(warning)); // Lazily-compute compositions if they're asked for Object.defineProperty(result, "compositions", { get : () => compositions(this), }); return result; } // Expose files get files() { return this._files; } // Expose combined options object get options() { return this._options; } // Expose the dependency graph get graph() { return this._graph; } // Return all the compositions for the files loaded into the processor instance get compositions() { // Ensure all files are fully-processed first return Promise.all( Object.values(this._files).map(({ result }) => result) ) .then(() => compositions(this)); } get warnings() { return this._warnings; } _dupeCheck(file) { const check = file.toLowerCase(); // Warn about potential dupes if an ID goes past we've seen before if(this._options.dupewarn) { const other = this._ids.get(check); if(other && other !== file) { // eslint-disable-next-line no-console -- warning console.warn(`POTENTIAL DUPLICATE FILES:\n\t${other}\n\t${file}`); } } this._ids.set(check, file); } _addFile(file) { this._dupeCheck(file); const key = fileKey(file); if(!this._graph.hasNode(key)) { this._graph.addNode(key, { file, selectors : [], values : [], }); } return key; } _addSelector(file, selector) { const sKey = selectorKey(file, selector); // Ensure the file always exists const fKey = this._addFile(file); if(!this._graph.hasNode(sKey)) { this._graph.addNode(sKey, { file, selector, valid : true, }); this._graph.getNodeData(fKey).selectors.push(sKey); this._graph.addDependency(fKey, sKey); } else { this._graph.getNodeData(sKey).valid = true; } return sKey; } _addGlobal(selector, opts = false) { const file = "global"; const key = selectorKey(file, selector); if(!this._graph.hasNode(key)) { this._graph.addNode(key, { file, selector, global : true, ...opts, }); } return key; } _addValue(file, name, opts = false) { const vKey = valueKey(file, name); // Ensure the file always exists const fKey = this._addFile(file); if(!this._graph.hasNode(vKey)) { this._graph.addNode(vKey, { file, value : name, ...opts, }); this._graph.getNodeData(fKey).values.push(vKey); this._graph.addDependency(fKey, vKey); } return vKey; } _addDependency({ selector, refs = [], dependency, name }) { const { _graph : graph } = this; const dep = this._normalize(dependency); const fKey = this._addFile(name); const dKey = this._addFile(dep); graph.addDependency(fKey, dKey); // @values don't have a selector field if(!selector) { refs.forEach(({ name : depValue }) => { this._addValue(dep, depValue); }); return; } // Add selector and its dependencies to the graph this._addSelector(name, selector); refs.forEach(({ name : depSelector }) => { this._addSelector(dep, depSelector); }); } // Take a file id and some text, walk it for dependencies, then // process and return details async _add(id, src) { this._dupeCheck(id); this._log("_add()", id); await this._walk(id, src); const deps = [ ...filterByPrefix(FILE_PREFIX, this._graph.dependenciesOf(fileKey(id))), id ]; for(const dep of deps) { const file = this._files[dep]; if(!file.processed) { this._log("_process()", dep); file.processed = this._process.process( file.before, params(this, { from : dep, namer : this._options.namer, }) ); } // eslint-disable-next-line no-await-in-loop -- it's cool file.result = await file.processed; const { result } = file; const { messages } = result; result.warnings().forEach((warning) => this._warnings.push(warning)); // Save off anything from plugins named "modular-css-export*" // https://github.com/tivac/modular-css/pull/404 Object.assign(file.exported, messages.reduce((out, { plugin, exports : exported }) => { if(plugin && plugin.startsWith("modular-css-export") && exported) { Object.assign(out, exported); } return out; }, Object.create(null))); } const self = this; return { __proto__ : null, id, details : this._files[id], // Lazily-compute compositions if they're asked for get exports() { return fileCompositions(this.details, self); }, }; } // Process files and walk their composition/value dependency tree to find // new files we need to process async _walk(name, src) { const { _graph : graph, _files : files } = this; // No need to re-process files unless they've been marked invalid if(files[name] && files[name].valid) { // Do want to wait until they're done being processed though await files[name].walked; return; } const fKey = fileKey(name); // Clean up old graph dependencies for this node since it's about to be parsed // and they'll all be recreated anyways if(graph.hasNode(fKey)) { graph.directDependenciesOf(fKey).forEach((dep) => { const data = this._graph.getNodeData(dep); if(data.file !== name) { return; } graph.directDependenciesOf(dep).forEach((dep2) => graph.removeDependency(dep, dep2)); }); } this._addFile(name); this._log("_before()", name); let walked; const file = files[name] = { name, text : typeof src === "string" ? src : src.source.input.css, valid : true, classes : Object.create(null), values : Object.create(null), exported : Object.create(null), walked : new Promise((done) => (walked = done)), before : null, }; file.before = this._before.process( src, params(this, { from : name, }) ); await file.before; // Surface any warnings from that run file.before.warnings().forEach((msg) => this._warnings.push(msg)); // Walk this node's dependencies, reading new files from disk as necessary await Promise.all( filterByPrefix(FILE_PREFIX, graph.dependenciesOf(fKey)).map((dependency) => { const { valid, walked : complete } = files[dependency] || false; // If the file hasn't been invalidated wait for it to be done processing if(valid) { return complete; } // Otherwise add it to the queue return this.file(dependency); }) ); // Mark the walk of this file & its dependencies complete walked(); } } // Static exports of key.js functionality Object.assign(Processor, keys); module.exports = Processor;