diff --git a/README.md b/README.md index 0dbf88d..2339432 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,18 @@ For further usage examples, refer to the usual Browserify documentation. `icssif Plugin options (all optional): +* __extensions:__ An array of extensions (without the leading dot!) that should be considered "CSS files"; this is useful when you eg. name your files `.postcss` to indicate that you are using non-standard syntax. This list of extensions will *replace* the default list of extensions, so you will need to explicitly specify `"css"` in the list, if you want to keep parsing `.css` files. Defaults to `[ "css" ]`. * __mode:__ Whether to assume that untagged class names in your CSS (ie. those without a `:local` or `:global` tag) are local or global. Defaults to `"local"`, but you can set this to `"global"` if you want to make the class name mangling *opt-in*. You'll generally want to leave this at the default setting. * __before:__ PostCSS transforms to run *before* the ICSS transforms, ie. before imports/exports are analyzed. This is usually where you want custom PostCSS plugins to go. * __after:__ PostCSS transforms to run *after* the ICSS transforms, ie. after mangling the class names, but before bundling it all together into a single file. You'll rarely need to use this. + ## Changelog +### v1.2.0 (March 6, 2020) + +- __Feature:__ You can now specify custom `extensions` that should be treated as CSS files, besides `".css"`. + ### v1.1.1 (February 16, 2020) - __Bug__:__ Removed stray console.log call. diff --git a/index.js b/index.js index cca807a..00743cc 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,8 @@ "use strict"; -const { validateArguments, required, oneOf, arrayOf, allowExtraProperties, ValidationError } = require("validatem"); +const { validateArguments, required, oneOf, arrayOf, isString, allowExtraProperties, ValidationError } = require("validatem"); -const createTransform = require("./src/transform"); -const createCssDepsStream = require("./src/phase-streams/deps-css"); -const createSyntaxHideStream = require("./src/phase-streams/syntax-hide"); -const createSyntaxUnhideStream = require("./src/phase-streams/syntax-unhide"); const createKahnSortingStream = require("./src/phase-streams/sort-kahn"); -const createDedupeBundleCssStream = require("./src/phase-streams/dedupe-bundle-css"); function isPostcssPlugin(value) { if (value.postcssPlugin == null || value.postcssVersion == null || typeof value !== "function") { @@ -17,48 +12,57 @@ function isPostcssPlugin(value) { // FIXME: When there's >1 icssify instance set up on the Browserify instance (can this happen when a module specifies its own dependency on icssify?), either throw an error (if incompatible?) or merge the two somehow, so that there's only one resulting .css file -function setupPipeline(browserify, options) { - /* Not shown here: the default 'deps' phase stream will process all the CSS files through our custom transform (defined elsewhere). That transform handles the discovery of CSS dependencies, by pre-processing the CSS files (normalizing 'composes' statements etc. into :import/:export statements), and then extracting all the imports from them, emitting them to Browserify as additional dependencies to fetch. */ - - let depsPhase = browserify.pipeline.get("deps"); - - /* This stream marks CSS files as 'discardable' when they are only required by other CSS files and not by any JS files; this allows for their to be stripped from the final bundle, saving space. */ - depsPhase.push(createCssDepsStream()); - - let syntaxPhase = browserify.pipeline.get("syntax"); - - /* These streams sneak the CSS past the JS syntax checker contained in Browserify, by encoding it in a "JS" file that contains a comment (which will always pass validation), and unpacking it into CSS again after the syntax check has occurred. This effectively opts the CSS files out of the syntax check, which only knows about JS. */ - syntaxPhase.unshift(createSyntaxHideStream()); - syntaxPhase.push(createSyntaxUnhideStream()); - - let sortPhase = browserify.pipeline.get("sort"); - - /* This stream re-sorts the dependencies into dependency order using Kahn's algorithm; that is to say, each dependency always appears before the file(s) that depend on it. This is needed for our final CSS processing (when we resolve the exported/imported identifiers between them), to make sure that we've processed all the dependencies of a file before processing the file itself (as otherwise we wouldn't know all the resolve identifiers to use). This is a deterministic algorith, which means it *does not* break the determinism guarantee of Browserify's own hash-sorting mechanism, and shouldn't interfere with anything else. It could be broken by other plugins adding their own sorting mechanism after this one, though; so this plugin should be loaded last. */ - sortPhase.push(createKahnSortingStream()); - - let dedupePhase = browserify.pipeline.get("dedupe"); - - /* This stream does the actual bundling of CSS files. It does the final processing of all CSS files (ICSS :import/:export resolution, identifier replacing, etc.), then throws away all the files that were marked as 'discardable' earlier, converts the remaining files into JS-formatted class name exports (for use with eg. JSX), and finally inserts the fully-bundled CSS into the loader shim. Ta-da! */ - dedupePhase.push(createDedupeBundleCssStream(options)); -} - module.exports = function (browserify, options) { validateArguments(arguments, [ [ "browserify" ], [ "options", allowExtraProperties({ mode: oneOf([ "local", "global" ]), before: arrayOf([ required, isPostcssPlugin ]), - after: arrayOf([ required, isPostcssPlugin ]) + after: arrayOf([ required, isPostcssPlugin ]), + extensions: arrayOf([ required, isString ]) })] ]); + let state = { extensions: options.extensions }; + + const createTransform = require("./src/transform")(state); + const createCssDepsStream = require("./src/phase-streams/deps-css")(state); + const createSyntaxHideStream = require("./src/phase-streams/syntax-hide")(state); + const createSyntaxUnhideStream = require("./src/phase-streams/syntax-unhide")(state); + const createDedupeBundleCssStream = require("./src/phase-streams/dedupe-bundle-css")(state); + + function setupPipeline() { + /* Not shown here: the default 'deps' phase stream will process all the CSS files through our custom transform (defined elsewhere). That transform handles the discovery of CSS dependencies, by pre-processing the CSS files (normalizing 'composes' statements etc. into :import/:export statements), and then extracting all the imports from them, emitting them to Browserify as additional dependencies to fetch. */ + + let depsPhase = browserify.pipeline.get("deps"); + + /* This stream marks CSS files as 'discardable' when they are only required by other CSS files and not by any JS files; this allows for their to be stripped from the final bundle, saving space. */ + depsPhase.push(createCssDepsStream()); + + let syntaxPhase = browserify.pipeline.get("syntax"); + + /* These streams sneak the CSS past the JS syntax checker contained in Browserify, by encoding it in a "JS" file that contains a comment (which will always pass validation), and unpacking it into CSS again after the syntax check has occurred. This effectively opts the CSS files out of the syntax check, which only knows about JS. */ + syntaxPhase.unshift(createSyntaxHideStream()); + syntaxPhase.push(createSyntaxUnhideStream()); + + let sortPhase = browserify.pipeline.get("sort"); + + /* This stream re-sorts the dependencies into dependency order using Kahn's algorithm; that is to say, each dependency always appears before the file(s) that depend on it. This is needed for our final CSS processing (when we resolve the exported/imported identifiers between them), to make sure that we've processed all the dependencies of a file before processing the file itself (as otherwise we wouldn't know all the resolve identifiers to use). This is a deterministic algorith, which means it *does not* break the determinism guarantee of Browserify's own hash-sorting mechanism, and shouldn't interfere with anything else. It could be broken by other plugins adding their own sorting mechanism after this one, though; so this plugin should be loaded last. */ + sortPhase.push(createKahnSortingStream()); + + let dedupePhase = browserify.pipeline.get("dedupe"); + + /* This stream does the actual bundling of CSS files. It does the final processing of all CSS files (ICSS :import/:export resolution, identifier replacing, etc.), then throws away all the files that were marked as 'discardable' earlier, converts the remaining files into JS-formatted class name exports (for use with eg. JSX), and finally inserts the fully-bundled CSS into the loader shim. Ta-da! */ + dedupePhase.push(createDedupeBundleCssStream(options)); + } + browserify.transform(createTransform, options); browserify.on("reset", () => { - setupPipeline(browserify, options); + setupPipeline(); }); - setupPipeline(browserify, options); + setupPipeline(); return browserify; }; diff --git a/src/css-only-stream.js b/src/css-only-stream.js index c44378a..a113019 100644 --- a/src/css-only-stream.js +++ b/src/css-only-stream.js @@ -1,14 +1,17 @@ "use strict"; const stream = require("./stream"); -const isCss = require("./is-css"); -module.exports = function cssOnlyStream(handler, flushHandler) { - return stream((item) => { - if (isCss(item)) { - return handler(item); - } else { - return [ item ]; - } - }, flushHandler); +module.exports = function (state) { + const isCss = require("./is-css")(state); + + return function cssOnlyStream(handler, flushHandler) { + return stream((item) => { + if (isCss(item)) { + return handler(item); + } else { + return [ item ]; + } + }, flushHandler); + } }; diff --git a/src/is-css.js b/src/is-css.js index fe6473c..e86e894 100644 --- a/src/is-css.js +++ b/src/is-css.js @@ -1,5 +1,16 @@ "use strict"; -module.exports = function isCss(item) { - return /\.css$/.test(item.file); +const defaultValue = require("default-value"); +const path = require("path"); + +// Yes, there is a good reason this is a separate module. In the future we may need to add more heuristics to determine whether a "CSS file" *really* is a CSS file, rather than just going off the filename, and this centralizes the checking logic we'd need to change for that. If this ever occurs, though, keep in mind that this function doesn't always get a *full* file object (sometimes it just gets an object with a filename), so any such heuristics would need to be able to deal with that. + +module.exports = function (state) { + let targetExtensions = new Set(defaultValue(state.extensions, [ "css" ])); + + return function isCss(item) { + let itemExtension = path.extname(item.file).replace(/^\./, ""); + + return targetExtensions.has(itemExtension); + }; }; diff --git a/src/phase-streams/dedupe-bundle-css.js b/src/phase-streams/dedupe-bundle-css.js index c4dd469..070d594 100644 --- a/src/phase-streams/dedupe-bundle-css.js +++ b/src/phase-streams/dedupe-bundle-css.js @@ -5,57 +5,60 @@ const path = require("path"); const stream = require("../stream"); const createFilePostprocessor = require("../postcss/postprocess"); -const isCss = require("../is-css"); const loaderShimPath = require("../loader-shim-path"); // NOTE: This stream is inserted directly after the 'dedupe' phase, and *not* before the 'pack' phase like in postcssify-icss, because that would break `extract-css` - which inserts itself before the 'label' phase that comes after the 'dedupe' phase, and by which time all the CSS needs to be prepared. -module.exports = function createDedupeBundleCssStream(options) { - // TODO: Reuse instance? - let processFile = createFilePostprocessor(options); - let allCss = ""; - let loaderItem; +module.exports = function (state) { + const isCss = require("../is-css")(state); - let entryPoint = (options._flags.entries != null) - // Get the (absolute!) path of the folder containing the initial entry file, as a reference point for relative paths in the output - ? path.dirname(path.resolve(options._flags.entries[0])) - // ... or, if no entry file is specified, go off the current working directory - : process.cwd() - - return stream((item) => { - // And the same for the loader shim path. All this relative-path stuff is to prevent absolute filesystem URLs from leaking into the output, as those might contain sensitive information. - let relativeLoaderPath = path.relative(path.dirname(item.file), loaderShimPath); - - if (isCss(item)) { - return Promise.try(() => { - return processFile(item); - }).then(({ result, icssExports }) => { - allCss += `/* from ${path.relative(entryPoint, item.file)} */\n\n${result.css}\n\n`; - - if (!item.__icssify__discardable) { + return function createDedupeBundleCssStream(options) { + // TODO: Reuse instance? + let processFile = createFilePostprocessor(options); + let allCss = ""; + let loaderItem; + + let entryPoint = (options._flags.entries != null) + // Get the (absolute!) path of the folder containing the initial entry file, as a reference point for relative paths in the output + ? path.dirname(path.resolve(options._flags.entries[0])) + // ... or, if no entry file is specified, go off the current working directory + : process.cwd() + + return stream((item) => { + // And the same for the loader shim path. All this relative-path stuff is to prevent absolute filesystem URLs from leaking into the output, as those might contain sensitive information. + let relativeLoaderPath = path.relative(path.dirname(item.file), loaderShimPath); + + if (isCss(item)) { + return Promise.try(() => { + return processFile(item); + }).then(({ result, icssExports }) => { + allCss += `/* from ${path.relative(entryPoint, item.file)} */\n\n${result.css}\n\n`; + + if (!item.__icssify__discardable) { + return { + ... item, + source: `require(${JSON.stringify(relativeLoaderPath)}); module.exports = ${JSON.stringify(icssExports)};` + }; + } + }); + } else if (item.file === loaderShimPath) { + // Stockpile this item, to emit at the very end, right before the browser-pack operation (because by that time, we'll have processed and therefore concatenated all the CSS files) + loaderItem = item; + return null; + } else { + return item; + } + }, () => { + if (allCss.length > 0) { + if (loaderItem != null) { return { - ... item, - source: `require(${JSON.stringify(relativeLoaderPath)}); module.exports = ${JSON.stringify(icssExports)};` + ... loaderItem, + source: loaderItem.source.replace('"## CONTENT MARKER ##"', JSON.stringify(allCss)) }; + } else { + throw new Error("Processed CSS, but global loader was not encountered. This should never happen, please report it as a bug!"); } - }); - } else if (item.file === loaderShimPath) { - // Stockpile this item, to emit at the very end, right before the browser-pack operation (because by that time, we'll have processed and therefore concatenated all the CSS files) - loaderItem = item; - return null; - } else { - return item; - } - }, () => { - if (allCss.length > 0) { - if (loaderItem != null) { - return { - ... loaderItem, - source: loaderItem.source.replace('"## CONTENT MARKER ##"', JSON.stringify(allCss)) - }; - } else { - throw new Error("Processed CSS, but global loader was not encountered. This should never happen, please report it as a bug!"); } - } - }); + }); + }; }; diff --git a/src/phase-streams/deps-css.js b/src/phase-streams/deps-css.js index 68da79a..10043dc 100644 --- a/src/phase-streams/deps-css.js +++ b/src/phase-streams/deps-css.js @@ -1,38 +1,41 @@ "use strict"; const stream = require("../stream"); -const isCss = require("../is-css"); -module.exports = function createCssDepsStream() { - let dependents = new Map(); - let depsPhaseItems = []; - - function handleItem(item) { - Object.values(item.deps).forEach((dependency) => { - if (!dependents.has(dependency)) { - dependents.set(dependency, new Set()); - } - - dependents.get(dependency).add(item); - }); - - depsPhaseItems.push(item); - } - - function flush() { - return depsPhaseItems.map((item) => { - let selfIsCss = isCss(item); - - let dependentsForItem = dependents.get(item.id); - let hasDependents = (dependentsForItem != null); - let dependentsAreCss = hasDependents && Array.from(dependentsForItem).every((dependent) => isCss(dependent)); - - return { - ... item, - __icssify__discardable: (selfIsCss && dependentsAreCss) - }; - }); - } - - return stream(handleItem, flush); +module.exports = function (state) { + const isCss = require("../is-css")(state); + + return function createCssDepsStream() { + let dependents = new Map(); + let depsPhaseItems = []; + + function handleItem(item) { + Object.values(item.deps).forEach((dependency) => { + if (!dependents.has(dependency)) { + dependents.set(dependency, new Set()); + } + + dependents.get(dependency).add(item); + }); + + depsPhaseItems.push(item); + } + + function flush() { + return depsPhaseItems.map((item) => { + let selfIsCss = isCss(item); + + let dependentsForItem = dependents.get(item.id); + let hasDependents = (dependentsForItem != null); + let dependentsAreCss = hasDependents && Array.from(dependentsForItem).every((dependent) => isCss(dependent)); + + return { + ... item, + __icssify__discardable: (selfIsCss && dependentsAreCss) + }; + }); + } + + return stream(handleItem, flush); + }; }; diff --git a/src/phase-streams/syntax-hide.js b/src/phase-streams/syntax-hide.js index 122ddbe..607d7ab 100644 --- a/src/phase-streams/syntax-hide.js +++ b/src/phase-streams/syntax-hide.js @@ -1,13 +1,16 @@ "use strict"; -const cssOnlyStream = require("../css-only-stream"); const hideCss = require("../hide-css"); -module.exports = function createSyntaxHideStream() { - return cssOnlyStream((file) => { - return { - ... file, - source: hideCss(file.source) - }; - }); +module.exports = function (state) { + const cssOnlyStream = require("../css-only-stream")(state); + + return function createSyntaxHideStream() { + return cssOnlyStream((file) => { + return { + ... file, + source: hideCss(file.source) + }; + }); + }; }; diff --git a/src/phase-streams/syntax-unhide.js b/src/phase-streams/syntax-unhide.js index e920013..c1ec61b 100644 --- a/src/phase-streams/syntax-unhide.js +++ b/src/phase-streams/syntax-unhide.js @@ -1,13 +1,16 @@ "use strict"; -const cssOnlyStream = require("../css-only-stream"); const unhideCss = require("../unhide-css"); -module.exports = function createSyntaxHideStream() { - return cssOnlyStream((file) => { - return { - ... file, - source: unhideCss(file.source) - }; - }); +module.exports = function (state) { + const cssOnlyStream = require("../css-only-stream")(state); + + return function createSyntaxHideStream() { + return cssOnlyStream((file) => { + return { + ... file, + source: unhideCss(file.source) + }; + }); + }; }; diff --git a/src/transform.js b/src/transform.js index 53b3ddb..90f440b 100644 --- a/src/transform.js +++ b/src/transform.js @@ -5,57 +5,60 @@ const bl = require("bl"); const through2 = require("through2"); const path = require("path"); -const isCss = require("./is-css"); const createFilePreprocessor = require("./postcss/preprocess"); const loaderShimPath = require("./loader-shim-path"); -module.exports = function createTransform(file, options) { - // TODO: Reuse instance? - let preprocessFile = createFilePreprocessor(options); +module.exports = function (state) { + const isCss = require("./is-css")(state); - if (isCss({ file: file })) { - let buffer = new bl.BufferList(); - - function chunkHandler(chunk, encoding, callback) { - // NOTE: We check the `chunk` here, not the `encoding`, because in at least one instance a Buffer was passed in claiming to be a "utf8" string according to the `encoding` argument - if (Buffer.isBuffer(chunk)) { - buffer.append(chunk); - callback(); - } else { - throw new Error(`Expected Buffer, got string instead (encoding = ${encoding})`); + return function createTransform(file, options) { + // TODO: Reuse instance? + let preprocessFile = createFilePreprocessor(options); + + if (isCss({ file: file })) { + let buffer = new bl.BufferList(); + + function chunkHandler(chunk, encoding, callback) { + // NOTE: We check the `chunk` here, not the `encoding`, because in at least one instance a Buffer was passed in claiming to be a "utf8" string according to the `encoding` argument + if (Buffer.isBuffer(chunk)) { + buffer.append(chunk); + callback(); + } else { + throw new Error(`Expected Buffer, got string instead (encoding = ${encoding})`); + } } - } - - function endHandler(callback) { - return Promise.try(() => { - return preprocessFile({ file: file, source: buffer.toString() }); - }).then((result) => { - let imports = result.messages - .filter((message) => { - return ( - message.pluginName === "postcss-icss-find-imports" - && message.type === "import" - ); - }) - .map((message) => message.url); - - imports.forEach((importUrl) => { - this.emit("dep", importUrl); + + function endHandler(callback) { + return Promise.try(() => { + return preprocessFile({ file: file, source: buffer.toString() }); + }).then((result) => { + let imports = result.messages + .filter((message) => { + return ( + message.pluginName === "postcss-icss-find-imports" + && message.type === "import" + ); + }) + .map((message) => message.url); + + imports.forEach((importUrl) => { + this.emit("dep", importUrl); + }); + + // This ensures that whenever *any* CSS file is processed, the global loader file gets included as well (which is the file that eventually inserts the concatenated CSS) + let relativeLoaderPath = path.relative(path.dirname(file), loaderShimPath); + this.emit("dep", relativeLoaderPath); + + this.push(result.css); + callback(); + }).catch((error) => { + callback(error); }); - - // This ensures that whenever *any* CSS file is processed, the global loader file gets included as well (which is the file that eventually inserts the concatenated CSS) - let relativeLoaderPath = path.relative(path.dirname(file), loaderShimPath); - this.emit("dep", relativeLoaderPath); - - this.push(result.css); - callback(); - }).catch((error) => { - callback(error); - }); + } + + return through2.obj(chunkHandler, endHandler); + } else { + return through2.obj(); } - - return through2.obj(chunkHandler, endHandler); - } else { - return through2.obj(); - } + }; };