├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE.txt ├── README.md ├── dist └── rollup-plugin-collect-sass.js ├── fixtures ├── _underscore.scss ├── _underscoresassext.sass ├── _underscorescssext.scss ├── bootstrap.scss ├── dedupe-js-output.css ├── dedupe-js.js ├── dedupe-output-importOnce.css ├── dedupe-output.css ├── dedupe.js ├── dupe-one.scss ├── dupe-two.scss ├── dupe.js ├── dupe.scss ├── first.scss ├── header.scss ├── imports-output.css ├── imports.js ├── imports.scss ├── multiline-output.css ├── multiline.js ├── multiline.scss ├── multiple-output.css ├── multiple.js ├── node-modules-js.js ├── node-modules-output.css ├── node-modules.js ├── nounderscore.scss ├── sassext.sass ├── scssext.scss ├── second.scss ├── simple-output.css ├── simple.js ├── simple.scss └── variables.scss ├── index.js ├── index.test.js ├── package.json └── rollup.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | fixtures/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | 4 | "globals": { 5 | "window": true, 6 | "test": true, 7 | "expect": true, 8 | "it": true 9 | }, 10 | 11 | "rules": { 12 | "semi": ["error", "never"], 13 | "indent": ["error", 4], 14 | "no-case-declarations": [0], 15 | "quote-props": ["error", "consistent"], 16 | "space-before-function-paren": ["error", { "anonymous": "never", "named": "always" }], 17 | "arrow-parens": [2, "as-needed", { "requireForBlockBody": false }], 18 | 19 | "import/prefer-default-export": [0], 20 | "import/no-extraneous-dependencies": [0], 21 | "import/extensions": [1, { "mjs": "never" }], 22 | 23 | "react/sort-comp": [0], 24 | "react/no-multi-comp": [0], 25 | "react/prop-types": [0], 26 | "react/jsx-indent": ["error", 4], 27 | "react/jsx-indent-props": ["error", 4], 28 | "react/jsx-filename-extension": [1, { "extensions": [".js"] }], 29 | 30 | "jsx-a11y/no-static-element-interactions": [0] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Nathan Cahill 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Rollup Plugin Collect Sass 3 | 4 | [![CircleCI](https://img.shields.io/circleci/project/github/nathancahill/rollup-plugin-collect-sass.svg)](https://circleci.com/gh/nathancahill/rollup-plugin-collect-sass) 5 | ![stability-stable](https://img.shields.io/badge/stability-stable-green.svg) 6 | 7 | > :sleeping: Tired: minimalist 'lightweight' libraries 8 | > 9 | > :zap: Wired: feature-rich compilers with lightweight output 10 | > 11 | 12 | — [Rich Harris](https://twitter.com/Rich_Harris/status/855012360892928000), creator of Rollup 13 | 14 | ## Why 15 | 16 | Most methods for transforming Sass with Rollup operate on an individual file level. In JS, writing `import './variables.scss'` followed by `import './header.scss'` will create independent contexts for each file when compiled (variables defined in `variables.scss` will not be available in `header.scss`). 17 | 18 | The common solution is to collect all Sass imports into a single Sass entrypoint (like `index.scss`), which is then imported once for Rollup. However, this solution is not ideal, because this second entrypoint must be kept in sync with the bundled components. 19 | 20 | Instead, each component could import the exact Sass files it requires. This is __especially useful for libraries, where modular components and CSS is desirable__. To support this, two problems must be solved: 21 | 22 | - Import bloat (duplicate Sass imports in the final bundle) 23 | - Single context (variables defined in one import are not available in the next) 24 | 25 | To this end, this plugin compiles Sass in two passes: It collects each Sass import (and resolves relative `@import` statements within the files), then does a second pass to compile all collected Sass to CSS, optionally deduplicating `@import` statements. 26 | 27 | ## Features 28 | 29 | - Processes all Sass encountered by Rollup in a single context, in import order. 30 | - Supports `node_modules` resolution, following the same Sass file name resolution algorithm. Importing from, for example, `bootstrap/scss/` Just Works™. 31 | - Optionally dedupes `@import` statements, including from `node_modules`. This prevents duplication of common imports shared by multiple components, promotes encapulation and allows modules to standalone if need be. 32 | - By default, inserts CSS in to `
`, although file output is supported as well with the `extract` option. 33 | 34 | ## Installation 35 | 36 | ``` 37 | npm install rollup-plugin-collect-sass --save-dev 38 | ``` 39 | 40 | ## Usage 41 | 42 | ``` 43 | import collectSass from 'rollup-plugin-collect-sass' 44 | 45 | export default { 46 | plugins: [ 47 | collectSass({ 48 | ...options 49 | }), 50 | ], 51 | } 52 | ``` 53 | 54 | ## Options 55 | 56 | ### `importOnce` 57 | 58 | Boolean, if set to `true`, all Sass `@import` statements are deduped after absolute paths are resolved. Default: `false` to match default libsass/Ruby Sass behavior. 59 | 60 | #### `extensions` 61 | 62 | File extensions to include in the transformer. Default: `['.scss', '.sass']` 63 | 64 | ### `include` 65 | 66 | minimatch glob pattern (or array) of files to include. Default: `['**/*.scss', '**/*.sass']` 67 | 68 | ### `exclude` 69 | 70 | minimatch glob pattern (or array) of files to exclude. 71 | 72 | ### `extract` 73 | 74 | Either a boolean or a string path for the file to extract CSS output to. If boolean `true`, defaults to the same path as the JS output with `.css` extension. Default: `false` 75 | 76 | If set to `false`, CSS is injected in to the header with JS. 77 | 78 | ### `extractPath` 79 | 80 | Another way to specify the output path. Ignored if `extract` is falsy. 81 | 82 | ## License 83 | 84 | Copyright (c) 2017 Nathan Cahill 85 | 86 | Permission is hereby granted, free of charge, to any person obtaining a copy 87 | of this software and associated documentation files (the "Software"), to deal 88 | in the Software without restriction, including without limitation the rights 89 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 90 | copies of the Software, and to permit persons to whom the Software is 91 | furnished to do so, subject to the following conditions: 92 | 93 | The above copyright notice and this permission notice shall be included in 94 | all copies or substantial portions of the Software. 95 | 96 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 97 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 98 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 99 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 100 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 101 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 102 | THE SOFTWARE. 103 | -------------------------------------------------------------------------------- /dist/rollup-plugin-collect-sass.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } 4 | 5 | var fs = _interopDefault(require('fs')); 6 | var path = _interopDefault(require('path')); 7 | var resolve = _interopDefault(require('resolve')); 8 | var styleInject = _interopDefault(require('style-inject')); 9 | var sass = _interopDefault(require('node-sass')); 10 | var rollupPluginutils = require('rollup-pluginutils'); 11 | var mkdirp = _interopDefault(require('mkdirp')); 12 | 13 | var START_COMMENT_FLAG = '/* collect-postcss-start'; 14 | var END_COMMENT_FLAG = 'collect-postcss-end */'; 15 | var ESCAPED_END_COMMENT_FLAG = 'collect-postcss-escaped-end * /'; 16 | var ESCAPED_END_COMMENT_REGEX = /collect-postcss-escaped-end \* \//g; 17 | 18 | var escapeRegex = function (str) { return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); }; 19 | 20 | var findRegex = new RegExp(((escapeRegex(START_COMMENT_FLAG)) + "([^]*?)" + (escapeRegex(END_COMMENT_FLAG))), 'g'); 21 | var replaceRegex = new RegExp(((escapeRegex(START_COMMENT_FLAG)) + "[^]*?" + (escapeRegex(END_COMMENT_FLAG)))); 22 | var importRegex = new RegExp('@import([^;]*);', 'g'); 23 | 24 | var importExtensions = ['.scss', '.sass']; 25 | var injectFnName = '__$styleInject'; 26 | var injectStyleFuncCode = styleInject 27 | .toString() 28 | .replace(/styleInject/, injectFnName); 29 | 30 | var index = function (options) { 31 | if ( options === void 0 ) options = {}; 32 | 33 | var extensions = options.extensions || importExtensions; 34 | var filter = rollupPluginutils.createFilter(options.include || ['**/*.scss', '**/*.sass'], options.exclude); 35 | var extract = Boolean(options.extract); 36 | var extractFn = typeof options.extract === 'function' ? options.extract : null; 37 | var extractPath = typeof options.extract === 'string' ? options.extract : null; 38 | var importOnce = Boolean(options.importOnce); 39 | 40 | var cssExtract = ''; 41 | var visitedImports = new Set(); 42 | 43 | return { 44 | name: 'collect-sass', 45 | intro: function intro () { 46 | if (extract) { 47 | return null 48 | } 49 | 50 | return injectStyleFuncCode 51 | }, 52 | transform: function transform (code, id) { 53 | var this$1 = this; 54 | 55 | if (!filter(id)) { return null } 56 | if (extensions.indexOf(path.extname(id)) === -1) { return null } 57 | 58 | var relBase = path.dirname(id); 59 | var fileImports = new Set([id]); 60 | visitedImports.add(id); 61 | 62 | // Resolve imports before lossing relative file info 63 | // Find all import statements to replace 64 | var transformed = code.replace(importRegex, function (match, p1) { 65 | var paths = p1.split(/[,]/).map(function (p) { 66 | var orgName = p.trim(); // strip whitespace 67 | var name = orgName; 68 | 69 | if (name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) { 70 | name = name.substring(1, name.length - 1); // string quotes 71 | } 72 | 73 | // Exclude CSS @import: http://sass-lang.com/documentation/file.SASS_REFERENCE.html#import 74 | if (path.extname(name) === '.css') { return orgName } 75 | if (name.startsWith('http://')) { return orgName } 76 | if (name.startsWith('url(')) { return orgName } 77 | 78 | var fileName = path.basename(name); 79 | var dirName = path.dirname(name); 80 | 81 | // libsass's file name resolution: https://github.com/sass/node-sass/blob/1b9970a/src/libsass/src/file.cpp#L300 82 | if (fs.existsSync(path.join(relBase, dirName, fileName))) { 83 | var absPath = path.join(relBase, name); 84 | 85 | if (importOnce && visitedImports.has(absPath)) { 86 | return null 87 | } 88 | 89 | visitedImports.add(absPath); 90 | fileImports.add(absPath); 91 | return JSON.stringify(absPath) 92 | } 93 | 94 | if (fs.existsSync(path.join(relBase, dirName, ("_" + fileName)))) { 95 | var absPath$1 = path.join(relBase, ("_" + name)); 96 | 97 | if (importOnce && visitedImports.has(absPath$1)) { 98 | return null 99 | } 100 | 101 | visitedImports.add(absPath$1); 102 | fileImports.add(absPath$1); 103 | return JSON.stringify(absPath$1) 104 | } 105 | 106 | for (var i = 0; i < importExtensions.length; i += 1) { 107 | var absPath$2 = path.join(relBase, dirName, ("_" + fileName + (importExtensions[i]))); 108 | 109 | if (fs.existsSync(absPath$2)) { 110 | if (importOnce && visitedImports.has(absPath$2)) { 111 | return null 112 | } 113 | 114 | visitedImports.add(absPath$2); 115 | fileImports.add(absPath$2); 116 | return JSON.stringify(absPath$2) 117 | } 118 | } 119 | 120 | for (var i$1 = 0; i$1 < importExtensions.length; i$1 += 1) { 121 | var absPath$3 = path.join(relBase, ("" + name + (importExtensions[i$1]))); 122 | 123 | if (fs.existsSync(absPath$3)) { 124 | if (importOnce && visitedImports.has(absPath$3)) { 125 | return null 126 | } 127 | 128 | visitedImports.add(absPath$3); 129 | fileImports.add(absPath$3); 130 | return JSON.stringify(absPath$3) 131 | } 132 | } 133 | 134 | var nodeResolve; 135 | 136 | try { 137 | nodeResolve = resolve.sync(path.join(dirName, ("_" + fileName)), { extensions: extensions }); 138 | } catch (e) {} // eslint-disable-line no-empty 139 | 140 | try { 141 | nodeResolve = resolve.sync(path.join(dirName, fileName), { extensions: extensions }); 142 | } catch (e) {} // eslint-disable-line no-empty 143 | 144 | if (nodeResolve) { 145 | if (importOnce && visitedImports.has(nodeResolve)) { 146 | return null 147 | } 148 | 149 | visitedImports.add(nodeResolve); 150 | fileImports.add(nodeResolve); 151 | return JSON.stringify(nodeResolve) 152 | } 153 | 154 | this$1.warn(("Unresolved path in " + id + ": " + name)); 155 | 156 | return orgName 157 | }); 158 | 159 | var uniquePaths = paths.filter(function (p) { return p !== null; }); 160 | 161 | if (uniquePaths.length) { 162 | return ("@import " + (uniquePaths.join(', ')) + ";") 163 | } 164 | 165 | return '' 166 | }); 167 | 168 | // Escape */ end comments 169 | transformed = transformed.replace(/\*\//g, ESCAPED_END_COMMENT_FLAG); 170 | 171 | // Add sass imports to bundle as JS comment blocks 172 | return { 173 | code: START_COMMENT_FLAG + transformed + END_COMMENT_FLAG, 174 | map: { mappings: '' }, 175 | dependencies: Array.from(fileImports), 176 | } 177 | }, 178 | transformBundle: function transformBundle (source) { 179 | // Reset paths 180 | visitedImports = new Set(); 181 | 182 | // Extract each sass file from comment blocks 183 | var accum = ''; 184 | var match = findRegex.exec(source); 185 | 186 | while (match !== null) { 187 | accum += match[1]; 188 | match = findRegex.exec(source); 189 | } 190 | 191 | if (accum) { 192 | // Add */ end comments back 193 | accum = accum.replace(ESCAPED_END_COMMENT_REGEX, '*/'); 194 | // Transform sass 195 | var css = sass.renderSync({ 196 | data: accum, 197 | includePaths: ['node_modules'], 198 | }).css.toString(); 199 | 200 | if (!extract) { 201 | var injected = injectFnName + "(" + (JSON.stringify(css)) + ");"; 202 | 203 | // Replace first instance with output. Remove all other instances 204 | return { 205 | code: source.replace(replaceRegex, injected).replace(findRegex, ''), 206 | map: { mappings: '' }, 207 | } 208 | } 209 | 210 | // Store css for writing 211 | cssExtract = css; 212 | } 213 | 214 | // Remove all other instances 215 | return { 216 | code: source.replace(findRegex, ''), 217 | map: { mappings: '' }, 218 | } 219 | }, 220 | onwrite: function onwrite (opts) { 221 | if (extract && cssExtract) { 222 | if (extractFn) { return extractFn(cssExtract, opts) } 223 | 224 | var destPath = extractPath || 225 | path.join(path.dirname(opts.dest), ((path.basename(opts.dest, path.extname(opts.dest))) + ".css")); 226 | 227 | return new Promise(function (resolveDir, rejectDir) { 228 | mkdirp(path.dirname(destPath), function (err) { 229 | if (err) { rejectDir(err); } 230 | else { resolveDir(); } 231 | }); 232 | }).then(function () { 233 | return new Promise(function (resolveExtract, rejectExtract) { 234 | 235 | fs.writeFile(destPath, cssExtract, function (err) { 236 | if (err) { rejectExtract(err); } 237 | resolveExtract(); 238 | }); 239 | }) 240 | }) 241 | } 242 | 243 | return null 244 | }, 245 | } 246 | }; 247 | 248 | module.exports = index; 249 | -------------------------------------------------------------------------------- /fixtures/_underscore.scss: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'underscore'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/_underscoresassext.sass: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'underscoresassext'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/_underscorescssext.scss: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'underscorescssext'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/bootstrap.scss: -------------------------------------------------------------------------------- 1 | 2 | @import 'bootstrap/scss/variables'; 3 | @import 'bootstrap/scss/mixins/border-radius.scss'; 4 | @import 'bootstrap/scss/mixins/alert.scss'; 5 | @import 'bootstrap/scss/alert'; 6 | -------------------------------------------------------------------------------- /fixtures/dedupe-js-output.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: red; } 3 | -------------------------------------------------------------------------------- /fixtures/dedupe-js.js: -------------------------------------------------------------------------------- 1 | 2 | import './dupe' 3 | import './simple.scss' 4 | -------------------------------------------------------------------------------- /fixtures/dedupe-output-importOnce.css: -------------------------------------------------------------------------------- 1 | body { background-color: green; } -------------------------------------------------------------------------------- /fixtures/dedupe-output.css: -------------------------------------------------------------------------------- 1 | body { background-color: green; }body { background-color: green; } -------------------------------------------------------------------------------- /fixtures/dedupe.js: -------------------------------------------------------------------------------- 1 | 2 | import './dupe-one.scss' 3 | import './dupe-two.scss' 4 | -------------------------------------------------------------------------------- /fixtures/dupe-one.scss: -------------------------------------------------------------------------------- 1 | 2 | @import 'dupe'; 3 | -------------------------------------------------------------------------------- /fixtures/dupe-two.scss: -------------------------------------------------------------------------------- 1 | 2 | @import 'dupe'; 3 | -------------------------------------------------------------------------------- /fixtures/dupe.js: -------------------------------------------------------------------------------- 1 | 2 | import './simple.scss' 3 | -------------------------------------------------------------------------------- /fixtures/dupe.scss: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | background-color: green; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/first.scss: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'first'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/header.scss: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | background-color: $background; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/imports-output.css: -------------------------------------------------------------------------------- 1 | @import url(extension.css); 2 | @import 'http://www.example.com/styles.css'; 3 | @import url("http://www.example.com/url.css"); 4 | body::after { 5 | content: 'underscore'; } 6 | body::after { 7 | content: 'nounderscore'; } 8 | body::after { 9 | content: 'underscorescssext'; } 10 | body::after { 11 | content: 'scssext'; } 12 | body::after { 13 | content: 'first'; } 14 | body::after { 15 | content: 'second'; } 16 | -------------------------------------------------------------------------------- /fixtures/imports.js: -------------------------------------------------------------------------------- 1 | 2 | import './imports.scss' 3 | -------------------------------------------------------------------------------- /fixtures/imports.scss: -------------------------------------------------------------------------------- 1 | 2 | @import 'underscore'; 3 | @import 'nounderscore'; 4 | @import 'underscorescssext'; 5 | @import 'scssext'; 6 | @import 'extension.css'; 7 | @import 'http://www.example.com/styles.css'; 8 | @import url('http://www.example.com/url.css'); 9 | @import 'first', 'second'; 10 | -------------------------------------------------------------------------------- /fixtures/multiline-output.css: -------------------------------------------------------------------------------- 1 | body { color: red; /* comment */ } -------------------------------------------------------------------------------- /fixtures/multiline.js: -------------------------------------------------------------------------------- 1 | 2 | import './multiline.scss' 3 | -------------------------------------------------------------------------------- /fixtures/multiline.scss: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | color: red; /* comment */ 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/multiple-output.css: -------------------------------------------------------------------------------- 1 | body { background-color: red; } -------------------------------------------------------------------------------- /fixtures/multiple.js: -------------------------------------------------------------------------------- 1 | 2 | import './variables.scss' 3 | import './header.scss' 4 | -------------------------------------------------------------------------------- /fixtures/node-modules-js.js: -------------------------------------------------------------------------------- 1 | 2 | import 'bootstrap/scss/_variables.scss' 3 | import 'bootstrap/scss/mixins/_border-radius.scss' 4 | import 'bootstrap/scss/mixins/_alert.scss' 5 | import 'bootstrap/scss/_alert.scss' 6 | -------------------------------------------------------------------------------- /fixtures/node-modules-output.css: -------------------------------------------------------------------------------- 1 | .alert { 2 | padding: 0.75rem 1.25rem; margin-bottom: 1rem; border: 1px solid transparent; border-radius: 0.25rem; } 3 | .alert-heading { 4 | color: inherit; } 5 | .alert-link { 6 | font-weight: bold; } 7 | .alert-dismissible .close { 8 | position: relative; top: -0.75rem; right: -1.25rem; padding: 0.75rem 1.25rem; color: inherit; } 9 | .alert-success { 10 | background-color: #dff0d8; border-color: #d0e9c6; color: #3c763d; } 11 | .alert-success hr { border-top-color: #c1e2b3; } 12 | .alert-success .alert-link { color: #2b542c; } 13 | .alert-info { 14 | background-color: #d9edf7; border-color: #bcdff1; color: #31708f; } 15 | .alert-info hr { border-top-color: #a6d5ec; } 16 | .alert-info .alert-link { color: #245269; } 17 | .alert-warning { 18 | background-color: #fcf8e3; border-color: #faf2cc; color: #8a6d3b; } 19 | .alert-warning hr { border-top-color: #f7ecb5; } 20 | .alert-warning .alert-link { color: #66512c; } 21 | .alert-danger { 22 | background-color: #f2dede; border-color: #ebcccc; color: #a94442; } 23 | .alert-danger hr { border-top-color: #e4b9b9; } 24 | .alert-danger .alert-link { color: #843534; } -------------------------------------------------------------------------------- /fixtures/node-modules.js: -------------------------------------------------------------------------------- 1 | 2 | import "./bootstrap.scss" 3 | -------------------------------------------------------------------------------- /fixtures/nounderscore.scss: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'nounderscore'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/sassext.sass: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'sassext'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/scssext.scss: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'scssext'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/second.scss: -------------------------------------------------------------------------------- 1 | 2 | body::after { 3 | content: 'second'; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/simple-output.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: red; } 3 | -------------------------------------------------------------------------------- /fixtures/simple.js: -------------------------------------------------------------------------------- 1 | 2 | import './simple.scss' 3 | -------------------------------------------------------------------------------- /fixtures/simple.scss: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | color: red; 4 | } 5 | -------------------------------------------------------------------------------- /fixtures/variables.scss: -------------------------------------------------------------------------------- 1 | 2 | $background: red; 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | import fs from 'fs' 3 | import path from 'path' 4 | import resolve from 'resolve' 5 | import styleInject from 'style-inject' 6 | import sass from 'node-sass' 7 | import { createFilter } from 'rollup-pluginutils' 8 | import mkdirp from 'mkdirp'; 9 | 10 | const START_COMMENT_FLAG = '/* collect-postcss-start' 11 | const END_COMMENT_FLAG = 'collect-postcss-end */' 12 | const ESCAPED_END_COMMENT_FLAG = 'collect-postcss-escaped-end * /' 13 | const ESCAPED_END_COMMENT_REGEX = /collect-postcss-escaped-end \* \//g 14 | 15 | const escapeRegex = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') 16 | 17 | const findRegex = new RegExp(`${escapeRegex(START_COMMENT_FLAG)}([^]*?)${escapeRegex(END_COMMENT_FLAG)}`, 'g') 18 | const replaceRegex = new RegExp(`${escapeRegex(START_COMMENT_FLAG)}[^]*?${escapeRegex(END_COMMENT_FLAG)}`) 19 | const importRegex = new RegExp('@import([^;]*);', 'g') 20 | 21 | const importExtensions = ['.scss', '.sass'] 22 | const injectFnName = '__$styleInject' 23 | const injectStyleFuncCode = styleInject 24 | .toString() 25 | .replace(/styleInject/, injectFnName) 26 | 27 | export default (options = {}) => { 28 | const extensions = options.extensions || importExtensions 29 | const filter = createFilter(options.include || ['**/*.scss', '**/*.sass'], options.exclude) 30 | const extract = Boolean(options.extract) 31 | const extractFn = typeof options.extract === 'function' ? options.extract : null 32 | const extractPath = typeof options.extract === 'string' ? options.extract : null 33 | const importOnce = Boolean(options.importOnce) 34 | 35 | let cssExtract = '' 36 | let visitedImports = new Set() 37 | 38 | return { 39 | name: 'collect-sass', 40 | intro () { 41 | if (extract) { 42 | return null 43 | } 44 | 45 | return injectStyleFuncCode 46 | }, 47 | transform (code, id) { 48 | if (!filter(id)) { return null } 49 | if (extensions.indexOf(path.extname(id)) === -1) { return null } 50 | 51 | const relBase = path.dirname(id) 52 | const fileImports = new Set([id]) 53 | visitedImports.add(id) 54 | 55 | // Resolve imports before lossing relative file info 56 | // Find all import statements to replace 57 | let transformed = code.replace(importRegex, (match, p1) => { 58 | const paths = p1.split(/[,]/).map(p => { 59 | const orgName = p.trim() // strip whitespace 60 | let name = orgName 61 | 62 | if (name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) { 63 | name = name.substring(1, name.length - 1) // string quotes 64 | } 65 | 66 | // Exclude CSS @import: http://sass-lang.com/documentation/file.SASS_REFERENCE.html#import 67 | if (path.extname(name) === '.css') { return orgName } 68 | if (name.startsWith('http://')) { return orgName } 69 | if (name.startsWith('url(')) { return orgName } 70 | 71 | const fileName = path.basename(name) 72 | const dirName = path.dirname(name) 73 | 74 | // libsass's file name resolution: https://github.com/sass/node-sass/blob/1b9970a/src/libsass/src/file.cpp#L300 75 | if (fs.existsSync(path.join(relBase, dirName, fileName))) { 76 | const absPath = path.join(relBase, name) 77 | 78 | if (importOnce && visitedImports.has(absPath)) { 79 | return null 80 | } 81 | 82 | visitedImports.add(absPath) 83 | fileImports.add(absPath) 84 | return JSON.stringify(absPath) 85 | } 86 | 87 | if (fs.existsSync(path.join(relBase, dirName, `_${fileName}`))) { 88 | const absPath = path.join(relBase, `_${name}`) 89 | 90 | if (importOnce && visitedImports.has(absPath)) { 91 | return null 92 | } 93 | 94 | visitedImports.add(absPath) 95 | fileImports.add(absPath) 96 | return JSON.stringify(absPath) 97 | } 98 | 99 | for (let i = 0; i < importExtensions.length; i += 1) { 100 | const absPath = path.join(relBase, dirName, `_${fileName}${importExtensions[i]}`) 101 | 102 | if (fs.existsSync(absPath)) { 103 | if (importOnce && visitedImports.has(absPath)) { 104 | return null 105 | } 106 | 107 | visitedImports.add(absPath) 108 | fileImports.add(absPath) 109 | return JSON.stringify(absPath) 110 | } 111 | } 112 | 113 | for (let i = 0; i < importExtensions.length; i += 1) { 114 | const absPath = path.join(relBase, `${name}${importExtensions[i]}`) 115 | 116 | if (fs.existsSync(absPath)) { 117 | if (importOnce && visitedImports.has(absPath)) { 118 | return null 119 | } 120 | 121 | visitedImports.add(absPath) 122 | fileImports.add(absPath) 123 | return JSON.stringify(absPath) 124 | } 125 | } 126 | 127 | let nodeResolve 128 | 129 | try { 130 | nodeResolve = resolve.sync(path.join(dirName, `_${fileName}`), { extensions }) 131 | } catch (e) {} // eslint-disable-line no-empty 132 | 133 | try { 134 | nodeResolve = resolve.sync(path.join(dirName, fileName), { extensions }) 135 | } catch (e) {} // eslint-disable-line no-empty 136 | 137 | if (nodeResolve) { 138 | if (importOnce && visitedImports.has(nodeResolve)) { 139 | return null 140 | } 141 | 142 | visitedImports.add(nodeResolve) 143 | fileImports.add(nodeResolve) 144 | return JSON.stringify(nodeResolve) 145 | } 146 | 147 | this.warn(`Unresolved path in ${id}: ${name}`) 148 | 149 | return orgName 150 | }) 151 | 152 | const uniquePaths = paths.filter(p => p !== null) 153 | 154 | if (uniquePaths.length) { 155 | return `@import ${uniquePaths.join(', ')};` 156 | } 157 | 158 | return '' 159 | }) 160 | 161 | // Escape */ end comments 162 | transformed = transformed.replace(/\*\//g, ESCAPED_END_COMMENT_FLAG) 163 | 164 | // Add sass imports to bundle as JS comment blocks 165 | return { 166 | code: START_COMMENT_FLAG + transformed + END_COMMENT_FLAG, 167 | map: { mappings: '' }, 168 | dependencies: Array.from(fileImports), 169 | } 170 | }, 171 | transformBundle (source) { 172 | // Reset paths 173 | visitedImports = new Set() 174 | 175 | // Extract each sass file from comment blocks 176 | let accum = '' 177 | let match = findRegex.exec(source) 178 | 179 | while (match !== null) { 180 | accum += match[1] 181 | match = findRegex.exec(source) 182 | } 183 | 184 | if (accum) { 185 | // Add */ end comments back 186 | accum = accum.replace(ESCAPED_END_COMMENT_REGEX, '*/') 187 | // Transform sass 188 | const css = sass.renderSync({ 189 | data: accum, 190 | includePaths: ['node_modules'], 191 | }).css.toString() 192 | 193 | if (!extract) { 194 | const injected = `${injectFnName}(${JSON.stringify(css)});` 195 | 196 | // Replace first instance with output. Remove all other instances 197 | return { 198 | code: source.replace(replaceRegex, injected).replace(findRegex, ''), 199 | map: { mappings: '' }, 200 | } 201 | } 202 | 203 | // Store css for writing 204 | cssExtract = css 205 | } 206 | 207 | // Remove all other instances 208 | return { 209 | code: source.replace(findRegex, ''), 210 | map: { mappings: '' }, 211 | } 212 | }, 213 | onwrite (opts) { 214 | if (extract && cssExtract) { 215 | if (extractFn) return extractFn(cssExtract, opts) 216 | 217 | const destPath = extractPath || 218 | path.join(path.dirname(opts.dest), `${path.basename(opts.dest, path.extname(opts.dest))}.css`) 219 | 220 | return new Promise((resolveDir, rejectDir) => { 221 | mkdirp(path.dirname(destPath), err => { 222 | if (err) { rejectDir(err) } 223 | else resolveDir() 224 | }); 225 | }).then(() => { 226 | return new Promise((resolveExtract, rejectExtract) => { 227 | 228 | fs.writeFile(destPath, cssExtract, err => { 229 | if (err) { rejectExtract(err) } 230 | resolveExtract() 231 | }) 232 | }) 233 | }) 234 | } 235 | 236 | return null 237 | }, 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | 2 | import fs from 'fs' 3 | import { rollup } from 'rollup' 4 | import resolve from 'rollup-plugin-node-resolve' 5 | 6 | import collectSass from './index.js' 7 | 8 | const unJS = str => str 9 | .trim() 10 | .replace(/\\n/g, '') 11 | .replace(/\n/g, '') 12 | .replace(/\\"/g, '"') 13 | 14 | test('simple', done => rollup({ 15 | entry: 'fixtures/simple.js', 16 | plugins: [ 17 | collectSass(), 18 | ], 19 | }).then(bundle => { 20 | const output = unJS(bundle.generate({ format: 'es' }).code) 21 | const expected = `"${unJS(fs.readFileSync('fixtures/simple-output.css').toString())}"` 22 | 23 | expect(output).toEqual(expect.stringContaining(expected)) 24 | done() 25 | })) 26 | 27 | test('supports sourcemaps', done => rollup({ 28 | entry: 'fixtures/simple.js', 29 | plugins: [ 30 | collectSass(), 31 | ], 32 | sourceMap: true, 33 | }).then(bundle => { 34 | const output = unJS(bundle.generate({ format: 'es' }).code) 35 | const expected = `"${unJS(fs.readFileSync('fixtures/simple-output.css').toString())}"` 36 | 37 | expect(output).toEqual(expect.stringContaining(expected)) 38 | done() 39 | })) 40 | 41 | test('imports', done => rollup({ 42 | entry: 'fixtures/imports.js', 43 | plugins: [ 44 | collectSass(), 45 | ], 46 | }).then(bundle => { 47 | const output = unJS(bundle.generate({ format: 'es' }).code) 48 | const expected = `"${unJS(fs.readFileSync('fixtures/imports-output.css').toString())}"` 49 | 50 | expect(output).toEqual(expect.stringContaining(expected)) 51 | done() 52 | })) 53 | 54 | test('multiple imports', done => rollup({ 55 | entry: 'fixtures/multiple.js', 56 | plugins: [ 57 | collectSass(), 58 | ], 59 | }).then(bundle => { 60 | const output = unJS(bundle.generate({ format: 'es' }).code) 61 | const expected = `"${unJS(fs.readFileSync('fixtures/multiple-output.css').toString())}"` 62 | 63 | expect(output).toEqual(expect.stringContaining(expected)) 64 | done() 65 | })) 66 | 67 | test('without importOnce', done => rollup({ 68 | entry: 'fixtures/dedupe.js', 69 | plugins: [ 70 | collectSass(), 71 | ], 72 | }).then(bundle => { 73 | const output = unJS(bundle.generate({ format: 'es' }).code) 74 | const expected = `"${unJS(fs.readFileSync('fixtures/dedupe-output.css').toString())}"` 75 | 76 | expect(output).toEqual(expect.stringContaining(expected)) 77 | done() 78 | })) 79 | 80 | test('with importOnce', done => rollup({ 81 | entry: 'fixtures/dedupe.js', 82 | plugins: [ 83 | collectSass({ 84 | importOnce: true, 85 | }), 86 | ], 87 | }).then(bundle => { 88 | const output = unJS(bundle.generate({ format: 'es' }).code) 89 | const expected = `"${unJS(fs.readFileSync('fixtures/dedupe-output-importOnce.css').toString())}"` 90 | 91 | expect(output).toEqual(expect.stringContaining(expected)) 92 | done() 93 | })) 94 | 95 | test('with duplicate js imports', done => rollup({ 96 | entry: 'fixtures/dedupe-js.js', 97 | plugins: [ 98 | collectSass({ 99 | importOnce: true, 100 | }), 101 | ], 102 | }).then(bundle => { 103 | const output = unJS(bundle.generate({ format: 'es' }).code) 104 | const expected = `"${unJS(fs.readFileSync('fixtures/dedupe-js-output.css').toString())}"` 105 | 106 | expect(output).toEqual(expect.stringContaining(expected)) 107 | done() 108 | })) 109 | 110 | test('import node_modules', done => rollup({ 111 | entry: 'fixtures/node-modules.js', 112 | plugins: [ 113 | collectSass(), 114 | ], 115 | }).then(bundle => { 116 | const output = unJS(bundle.generate({ format: 'es' }).code) 117 | const expected = `"${unJS(fs.readFileSync('fixtures/node-modules-output.css').toString())}"` 118 | 119 | expect(output).toEqual(expect.stringContaining(expected)) 120 | done() 121 | })) 122 | 123 | test('import node_modules from js', done => rollup({ 124 | entry: 'fixtures/node-modules-js.js', 125 | plugins: [ 126 | resolve(), 127 | collectSass(), 128 | ], 129 | }).then(bundle => { 130 | const output = unJS(bundle.generate({ format: 'es' }).code) 131 | const expected = `"${unJS(fs.readFileSync('fixtures/node-modules-output.css').toString())}"` 132 | 133 | expect(output).toEqual(expect.stringContaining(expected)) 134 | done() 135 | })) 136 | 137 | test('with multiline comments', done => rollup({ 138 | entry: 'fixtures/multiline.js', 139 | plugins: [ 140 | collectSass(), 141 | ], 142 | }).then(bundle => { 143 | const output = unJS(bundle.generate({ format: 'es' }).code) 144 | const expected = `"${unJS(fs.readFileSync('fixtures/multiline-output.css').toString())}"` 145 | 146 | expect(output).toEqual(expect.stringContaining(expected)) 147 | done() 148 | })) 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rollup-plugin-collect-sass", 3 | "version": "1.0.9", 4 | "description": "Transform Sass in a single context", 5 | "main": "dist/rollup-plugin-collect-sass.js", 6 | "files": "dist", 7 | "scripts": { 8 | "build": "rollup -c", 9 | "test": "npm run lint && jest", 10 | "lint": "eslint ." 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/nathancahill/rollup-plugin-collect-sass.git" 15 | }, 16 | "author": "Nathan Cahill ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/nathancahill/rollup-plugin-collect-sass/issues" 20 | }, 21 | "homepage": "https://github.com/nathancahill/rollup-plugin-collect-sass#readme", 22 | "dependencies": { 23 | "mkdirp": "^0.5.1", 24 | "node-sass": ">= 3.8.0", 25 | "resolve": "^1.3.3", 26 | "rollup-pluginutils": ">= 1.3.1", 27 | "style-inject": "^0.1.0" 28 | }, 29 | "devDependencies": { 30 | "babel-jest": "^19.0.0", 31 | "babel-preset-es2015": "^6.24.1", 32 | "bootstrap": "^4.0.0-alpha.6", 33 | "eslint": "^3.14.1", 34 | "eslint-config-airbnb": "^14.0.0", 35 | "eslint-plugin-import": "^2.2.0", 36 | "eslint-plugin-jsx-a11y": "^3.0.2", 37 | "eslint-plugin-react": "^6.9.0", 38 | "jest": "^19.0.2", 39 | "rollup": "^0.41.6", 40 | "rollup-plugin-buble": "^0.15.0", 41 | "rollup-plugin-node-resolve": "^3.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | 2 | import buble from 'rollup-plugin-buble' 3 | 4 | export default { 5 | entry: 'index.js', 6 | format: 'cjs', 7 | dest: 'dist/rollup-plugin-collect-sass.js', 8 | external: [ 9 | 'fs', 10 | 'path', 11 | 'resolve', 12 | 'style-inject', 13 | 'node-sass', 14 | 'rollup-pluginutils', 15 | 'mkdirp', 16 | ], 17 | plugins: [ 18 | buble(), 19 | ], 20 | } 21 | --------------------------------------------------------------------------------