├── .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 | [](https://circleci.com/gh/nathancahill/rollup-plugin-collect-sass)
5 | 
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 |
--------------------------------------------------------------------------------