diff --git a/README.md b/README.md index 03cf41bd..8a3b7266 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ var css = require("css!./file.css"); Good loaders for requiring your assets are the [file-loader](https://github.com/webpack/file-loader) and the [url-loader](https://github.com/webpack/url-loader) which you should specify in your config (see below). -To be compatible with existing css files: +To be compatible with existing css files (if not in CSS Module mode): * `url(image.png)` => `require("./image.png")` * `url(~module/image.png)` => `require("module/image.png")` @@ -58,6 +58,8 @@ The result is: * `url(/image.png)` => `require("./image.png")` +Using 'Root-relative' urls is not recommended. You should only use it for legacy CSS files. + ### Local scope By default CSS exports all class names into a global selector scope. This is a feature which offer a local selector scope. @@ -97,28 +99,30 @@ exports.locals = { Camelcasing is recommended for local selectors. They are easier to use in the importing javascript module. +`url(...)` URLs in block scoped (`:local .abc`) rules behave like requests in modules: + * `./file.png` instead of `file.png` + * `module/file.png` instead of `~module/file.png` + + You can use `:local(#someId)`, but this is not recommended. Use classes instead of ids. You can configure the generated ident with the `localIdentName` query parameter (default `[hash:base64]`). Example: `css-loader?localIdentName=[path][name]---[local]---[hash:base64:5]` for easier debugging. -Note: For prerendering with extract-text-webpack-plugin you should use `css-loader/locals` instead of `style-loader!css-loader` in the prerendering bundle. It doesn't embed CSS but only exports the identifier mappings. +Note: For prerendering with extract-text-webpack-plugin you should use `css-loader/locals` instead of `style-loader!css-loader` **in the prerendering bundle**. It doesn't embed CSS but only exports the identifier mappings. ### Module mode (experimental) -The query parameter `module` enables **CSS Module** mode. (`css-loader?module`) +See [CSS Modules](https://github.com/css-modules/css-modules). -* Local scoped by default. -* `url(...)` URLs behave like requests in modules: - * `./file.png` instead of `file.png` - * `module/file.png` instead of `~module/file.png` +The query parameter `module` enables **CSS Module** mode. (`css-loader?module`) -Thanks to [@markdalgleish](https://github.com/markdalgleish) for prior work on this topic. +This enables Local scoped CSS by default. (You can leave it with `:global(...)` or `:global` for selectors and/or rules.) -### Inheriting +### Composing CSS classes -When declaring a local class name you can inherit from another local class name. +When declaring a local class name you can compose a local class from another local class name. ``` css :local(.className) { @@ -127,7 +131,7 @@ When declaring a local class name you can inherit from another local class name. } :local(.subClass) { - extends: className; + composes: className; background: blue; } ``` @@ -160,26 +164,25 @@ To import a local class name from another module: ``` css :local(.continueButton) { - extends: button from "library/button.css"; + composes: button from "library/button.css"; background: red; } ``` ``` css :local(.nameEdit) { - extends: edit highlight from "./edit.css"; + composes: edit highlight from "./edit.css"; background: red; } ``` -To import from multiple modules use multiple `extends:` rules. You can also use `url(...)` to specify the module (it behave a bit different). +To import from multiple modules use multiple `composes:` rules. ``` css :local(.className) { - extends: edit hightlight from "./edit.css"; - extends: button from url("button.css"); - /* equal to 'extends: button from "./button.css";' */ - extends: classFromThisModule; + composes: edit hightlight from "./edit.css"; + composes: button from "module/button.css"; + composes: classFromThisModule; background: red; } ``` @@ -192,6 +195,8 @@ To include SourceMaps set the `sourceMap` query param. I. e. the extract-text-webpack-plugin can handle them. +They are not enabled by default because they expose a runtime overhead and increase in bundle size (JS SourceMap do not). In addition to that relative paths are buggy and you need to use an absolute public path which include the server url. + ### importing and chained loaders The query parameter `importLoaders` allow to configure which loaders should be applied to `@import`ed resources. @@ -210,6 +215,8 @@ require("style-loader!css-loader!stylus-loader!...") require("css-loader!...") ``` +This may change in the future, when the module system (i. e. webpack) supports loader matching by origin. + ### Minification By default the css-loader minimizes the css if specified by the module system. diff --git a/lib/ReplaceMany.js b/lib/ReplaceMany.js deleted file mode 100644 index 5e98257c..00000000 --- a/lib/ReplaceMany.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php - Author Tobias Koppers @sokra -*/ -function ReplaceMany() { - this.replacements = []; -} - -module.exports = ReplaceMany; - -ReplaceMany.prototype.replace = function(start, length, newString) { - this.replacements.push([start, start + length, newString]); - return this; -}; - -ReplaceMany.prototype.run = function(string) { - this.replacements.sort(function(a, b) { - return b[0] - a[0]; - }); - var result = [string]; - this.replacements.forEach(function(repl) { - var str = result.pop(); - var done = str.substr(repl[1]); - var leftover = str.substr(0, repl[0]); - result.push(done, repl[2], leftover); - }); - result.reverse(); - return result.join(""); -}; diff --git a/lib/generateLocals.js b/lib/generateLocals.js deleted file mode 100644 index 6b2b5e93..00000000 --- a/lib/generateLocals.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php - Author Tobias Koppers @sokra -*/ -var loaderUtils = require("loader-utils"); -module.exports = function(locals, localExtends, importedUrls, importUrlPrefix, result, importAccess) { - var localKeys = Object.keys(locals); - if(localKeys.length > 0) { - var localLines = localKeys.map(function(key, idx) { - var line = " " + JSON.stringify(key) + ": "; - function addExtend(extend) { - if(extend.from) { - var importUrl = importUrlPrefix + - (extend.fromType === "url" ? loaderUtils.urlToRequest(extend.from) : extend.from); - if(importedUrls && result && importedUrls.indexOf(importUrl) < 0) { - result.push("exports.i(require(" + loaderUtils.stringifyRequest(this, importUrl) + "), \"\");"); - importedUrls.push(importUrl); - } - line += " + \" \" + require(" + loaderUtils.stringifyRequest(this, importUrl) + ")" + importAccess + "[" + JSON.stringify(extend.name) + "]"; - } else if(locals[extend.name]) { - line += " + \" \" + " + JSON.stringify(locals[extend.name]); - if(localExtends[extend.name]) { - localExtends[extend.name].forEach(addExtend, this); - } - } else if(this.emitError) { - this.emitError("Cannot extend from unknown class '" + extend.name + "'"); - } - } - line += JSON.stringify(locals[key]); - if(localExtends[key]) { - localExtends[key].forEach(addExtend, this); - } - if(idx !== localKeys.length - 1) line += ","; - return line; - }, this); - return "{\n" + localLines.join("\n") + "\n}"; - } -}; diff --git a/lib/getImportPrefix.js b/lib/getImportPrefix.js new file mode 100644 index 00000000..5d3be772 --- /dev/null +++ b/lib/getImportPrefix.js @@ -0,0 +1,14 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ +module.exports = function getImportPrefix(loaderContext, query) { + if(query.importLoaders === false) + return ""; + var importLoaders = parseInt(query.importLoaders, 10) || 0; + var loadersRequest = loaderContext.loaders.slice( + loaderContext.loaderIndex, + loaderContext.loaderIndex + 1 + importLoaders + ).map(function(x) { return x.request; }).join("!"); + return "-!" + loadersRequest + "!"; +}; diff --git a/lib/loader.js b/lib/loader.js index 6f1fcc5f..82dff7e6 100644 --- a/lib/loader.js +++ b/lib/loader.js @@ -3,160 +3,117 @@ Author Tobias Koppers @sokra */ var path = require("path"); -var parseSource = require("./parseSource"); -var ReplaceMany = require("./ReplaceMany"); var loaderUtils = require("loader-utils"); -var SourceListMap = require("source-list-map").SourceListMap; -var CleanCSS = require("clean-css"); +var processCss = require("./processCss"); +var getImportPrefix = require("./getImportPrefix"); + module.exports = function(content, map) { if(this.cacheable) this.cacheable(); var query = loaderUtils.parseQuery(this.query); var root = query.root; - var forceMinimize = query.minimize; - var importLoaders = parseInt(query.importLoaders, 10) || 0; - var minimize = typeof forceMinimize !== "undefined" ? !!forceMinimize : (this && this.minimize); var moduleMode = query.module; if(typeof map !== "string") { map = JSON.stringify(map); } - var result = []; - - // for importing CSS - var loadersRequest = this.loaders.slice( - this.loaderIndex, - this.loaderIndex + 1 + importLoaders - ).map(function(x) { return x.request; }).join("!"); - var importUrlPrefix = "-!" + loadersRequest + "!"; - - var stuff = parseSource(content); - - var replacer = new ReplaceMany(); + var result = processCss(content, map, { + mode: moduleMode ? "local" : "global", + from: loaderUtils.getRemainingRequest(this), + to: loaderUtils.getCurrentRequest(this), + query: query, + minimize: this.minimize, + loaderContext: this + }); - // store already imported files - var importedUrls = []; + var cssAsString = JSON.stringify(result.source); - // add @imports to result - stuff.imports.forEach(function(imp) { - replacer.replace(imp.start, imp.length, ""); + // for importing CSS + var importUrlPrefix = getImportPrefix(this, query); + + var alreadyImported = {}; + var importJs = result.importItems.filter(function(imp) { + if(!imp.mediaQuery) { + if(alreadyImported[imp.url]) + return false; + alreadyImported[imp.url] = true; + } + return true; + }).map(function(imp) { if(!loaderUtils.isUrlRequest(imp.url, root)) { - result.push("exports.push([module.id, " + + return "exports.push([module.id, " + JSON.stringify("@import url(" + imp.url + ");") + ", " + - JSON.stringify(imp.mediaQuery) + "]);"); + JSON.stringify(imp.mediaQuery) + "]);"; } else { - var importUrl = importUrlPrefix + - (moduleMode ? imp.url : loaderUtils.urlToRequest(imp.url)); - result.push("exports.i(require(" + loaderUtils.stringifyRequest(this, importUrl) + "), " + JSON.stringify(imp.mediaQuery) + ");"); - if(!imp.mediaQuery) - importedUrls.push(importUrl); - } - }, this); - - // replace url(...) - if(query.url !== false) { - stuff.urls.forEach(function(url, idx) { - replacer.replace(url.start, url.length, "__CSSLOADERURL_" + idx + "__"); - }); - } - - // replace :local() - var locals = {}; - var localExtends = {}; - require("./processLocals").call(this, stuff.selectors, query, replacer, locals, localExtends); - - // remove stuff - stuff.remove.forEach(function(rem) { - replacer.replace(rem.start, rem.length, ""); - }); - - // pass errors from parser - if(this.emitError) { - stuff.errors.forEach(function(err) { - this.emitError(err); - }, this); - } - - // generate the locals - var localsData = require("./generateLocals").call(this, locals, localExtends, importedUrls, importUrlPrefix, result, ".locals"); - if(localsData) { - result.push("exports.locals = " + localsData + ";"); - } - - // transform the CSS - var cssContent = replacer.run(content); - - // minimize CSS - if(minimize) { - var options = Object.create(query); - if(query.sourceMap && map) { - options.sourceMap = map; + var importUrl = importUrlPrefix + imp.url; + return "exports.i(require(" + loaderUtils.stringifyRequest(this, importUrl) + "), " + JSON.stringify(imp.mediaQuery) + ");"; } - var minimizeResult = new CleanCSS(options).minify(cssContent); - map = minimizeResult.sourceMap; - cssContent = minimizeResult.styles; - if(typeof map !== "string") - map = JSON.stringify(map); + }).join("\n"); + + function importItemMatcher(item) { + var match = result.importItemRegExp.exec(item); + var idx = +match[1]; + var importItem = result.importItems[idx]; + var importUrl = importUrlPrefix + importItem.url; + return "\" + require(" + loaderUtils.stringifyRequest(this, importUrl) + ").locals" + + "[" + JSON.stringify(importItem.export) + "] + \""; } - function toEmbStr(str) { - return JSON.stringify(str).replace(/^"|"$/g, ""); - } - - // replace url(...) in the generated code - var css = JSON.stringify(cssContent); - var urlRegExp = /__CSSLOADERURL_[0-9]+__/g; - css = css.replace(urlRegExp, function(str) { - var match = /^__CSSLOADERURL_([0-9]+)__$/.exec(str); - if(!match) return str; - var idx = parseInt(match[1], 10); - if(!stuff.urls[idx]) return str; - var urlItem = stuff.urls[idx]; + cssAsString = cssAsString.replace(result.importItemRegExpG, importItemMatcher.bind(this)).replace(result.urlItemRegExpG, function(item) { + var match = result.urlItemRegExp.exec(item); + var idx = +match[1]; + var urlItem = result.urlItems[idx]; var url = urlItem.url; - if(!loaderUtils.isUrlRequest(url, root)) - return toEmbStr(urlItem.raw); idx = url.indexOf("?#"); if(idx < 0) idx = url.indexOf("#"); var urlRequest; if(idx > 0) { // idx === 0 is catched by isUrlRequest // in cases like url('webfont.eot?#iefix') urlRequest = url.substr(0, idx); - if(!moduleMode) urlRequest = loaderUtils.urlToRequest(urlRequest, root); - return "\"+require(" + loaderUtils.stringifyRequest(this, urlRequest) + ")+\"" + url.substr(idx); + return "\" + require(" + loaderUtils.stringifyRequest(this, urlRequest) + ") + \"" + + url.substr(idx); } urlRequest = url; - if(!moduleMode) urlRequest = loaderUtils.urlToRequest(url, root); - return "\"+require(" + loaderUtils.stringifyRequest(this, urlRequest) + ")+\""; + return "\" + require(" + loaderUtils.stringifyRequest(this, urlRequest) + ") + \""; }.bind(this)); - // add a SourceMap - if(query.sourceMap && !minimize) { - var cssRequest = loaderUtils.getRemainingRequest(this); - var request = loaderUtils.getCurrentRequest(this); - if(!map) { - var sourceMap = new SourceListMap(); - sourceMap.add(content, cssRequest, content); - map = sourceMap.toStringWithSourceMap({ - file: request - }).map; - if(map.sources) { - map.sources = map.sources.map(function(source) { - var p = path.relative(query.context || this.options.context, source).replace(/\\/g, "/"); - if(p.indexOf("../") !== 0) - p = "./" + p; - return "/" + p; - }, this); - map.sourceRoot = "webpack://"; - } - map = JSON.stringify(map); + var exportJs = ""; + if(Object.keys(result.exports).length > 0) { + exportJs = Object.keys(result.exports).map(function(key) { + var valueAsString = JSON.stringify(result.exports[key]); + valueAsString = valueAsString.replace(result.importItemRegExpG, importItemMatcher.bind(this)); + return "\t" + JSON.stringify(key) + ": " + valueAsString; + }.bind(this)).join(",\n"); + exportJs = "exports.locals = {\n" + exportJs + "\n};"; + } + + + var moduleJs; + if(query.sourceMap && result.map) { + // add a SourceMap + map = result.map; + if(map.sources) { + map.sources = map.sources.map(function(source) { + var p = path.relative(query.context || this.options.context, source).replace(/\\/g, "/"); + if(p.indexOf("../") !== 0) + p = "./" + p; + return "/" + p; + }, this); + map.sourceRoot = "webpack://"; } - result.push("exports.push([module.id, " + css + ", \"\", " + map + "]);"); + map = JSON.stringify(map); + moduleJs = "exports.push([module.id, " + cssAsString + ", \"\", " + map + "]);"; } else { - result.push("exports.push([module.id, " + css + ", \"\"]);"); + moduleJs = "exports.push([module.id, " + cssAsString + ", \"\"]);"; } // embed runtime return "exports = module.exports = require(" + loaderUtils.stringifyRequest(this, require.resolve("./css-base.js")) + ")();\n" + - result.join("\n"); + "// imports\n" + + importJs + "\n\n" + + "// module\n" + + moduleJs + "\n\n" + + "// exports\n" + + exportJs; }; diff --git a/lib/localsLoader.js b/lib/localsLoader.js index 8bc067f4..50a2d4d6 100644 --- a/lib/localsLoader.js +++ b/lib/localsLoader.js @@ -3,30 +3,43 @@ Author Tobias Koppers @sokra */ var loaderUtils = require("loader-utils"); -var parseSource = require("./parseSource"); +var processCss = require("./processCss"); +var getImportPrefix = require("./getImportPrefix"); + module.exports = function(content) { if(this.cacheable) this.cacheable(); var query = loaderUtils.parseQuery(this.query); - var importLoaders = parseInt(query.importLoaders, 10) || 0; - - // for importing CSS - var loadersRequest = this.loaders.slice( - this.loaderIndex, - this.loaderIndex + 1 + importLoaders - ).map(function(x) { return x.request; }).join("!"); - var importUrlPrefix = "-!" + loadersRequest + "!"; - - var stuff = parseSource(content); + var moduleMode = query.module; - var locals = {}; - var localExtends = {}; - require("./processLocals").call(this, stuff.selectors, query, null, locals, localExtends); + var result = processCss(content, null, { + mode: moduleMode ? "local" : "global", + query: query, + minimize: this.minimize, + loaderContext: this + }); - - // generate the locals - var localsData = require("./generateLocals").call(this, locals, localExtends, null, importUrlPrefix, null, ""); - - - return "module.exports = " + localsData + ";"; + // for importing CSS + var importUrlPrefix = getImportPrefix(this, query); + + function importItemMatcher(item) { + var match = result.importItemRegExp.exec(item); + var idx = +match[1]; + var importItem = result.importItems[idx]; + var importUrl = importUrlPrefix + importItem.url; + return "\" + require(" + loaderUtils.stringifyRequest(this, importUrl) + ")" + + "[" + JSON.stringify(importItem.export) + "] + \""; + } + + var exportJs = ""; + if(Object.keys(result.exports).length > 0) { + exportJs = Object.keys(result.exports).map(function(key) { + var valueAsString = JSON.stringify(result.exports[key]); + valueAsString = valueAsString.replace(result.importItemRegExpG, importItemMatcher.bind(this)); + return "\t" + JSON.stringify(key) + ": " + valueAsString; + }.bind(this)).join(",\n"); + exportJs = "module.exports = {\n" + exportJs + "\n};"; + } + + return exportJs; }; diff --git a/lib/parseSource.js b/lib/parseSource.js deleted file mode 100644 index 178012a6..00000000 --- a/lib/parseSource.js +++ /dev/null @@ -1,419 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php - Author Tobias Koppers @sokra -*/ -var loaderUtils = require("loader-utils"); -var Parser = require("fastparse"); - -function errorMatch(message, newParserMode) { - return function(match) { - var index = arguments[arguments.length - 1]; - var nextLine = this.source.indexOf("\n", index); - var splittedSource = this.source.substr(0, index).split("\n"); - var line = splittedSource.length; - var lineBeforeError = splittedSource.pop(); - var lineAfterError = this.source.substr(index, nextLine); - this.errors.push("Unexpected '" + match + "' in line " + (line + 1) + ", " + message + "\n" + - lineBeforeError + lineAfterError + "\n" + lineBeforeError.replace(/[^\\s]/g, " ") + "^"); - return newParserMode; - }; -} - -function urlMatch(match, textBeforeUrl, replacedText, url, index) { - this.urls.push({ - url: url, - raw: replacedText, - start: index + textBeforeUrl.length, - length: replacedText.length - }); -} - -function importMatch(match, url, mediaQuery, index) { - this.imports.push({ - url: url, - mediaQuery: mediaQuery, - start: index, - length: match.length - }); -} - -function rulesStartMatch() { - this.blockMode = this.mode; - return "firstRule"; -} - -function rulesEndMatch() { - this.mode = null; - this.activeSelectors = []; - return "source"; -} - -function nextSelectorMatch() { - this.mode = null; -} - -function enableLocal(match, whitespace, index) { - this.remove.push({ - start: index + whitespace.length, - length: match.length - whitespace.length - }); - this.mode = "local"; -} - -function enableGlobal(match, whitespace, index) { - this.remove.push({ - start: index + whitespace.length, - length: match.length - whitespace.length - }); - this.mode = "global"; -} - -function localStart(match, whitespace, index) { - this.remove.push({ - start: index + whitespace.length, - length: match.length - whitespace.length - }); - this.bracketStatus = 0; - return "local"; -} - -function globalStart(match, whitespace, index) { - this.remove.push({ - start: index + whitespace.length, - length: match.length - whitespace.length - }); - this.bracketStatus = 0; - return "global"; -} - -function withMode(mode, fn) { - return function() { - var oldMode = this.mode; - this.mode = mode; - var newParserMode = fn.apply(this, arguments); - this.mode = oldMode; - return newParserMode; - }; -} - -function jump(newParserMode, fn) { - return function() { - fn.apply(this, arguments); - return newParserMode; - }; -} - -function innerBracketIn() { - this.bracketStatus++; -} - -function innerBracketOut(match, index) { - if(this.bracketStatus-- === 0) { - this.remove.push({ - start: index, - length: match.length - }); - return "source"; - } -} - -function selectorMatch(match, prefix, name, index) { - var selector = { - name: name, - prefix: prefix, - start: index, - length: match.length, - mode: this.mode - }; - this.selectors.push(selector); - this.activeSelectors.push(selector); -} - -function ruleScopedMatch() { - this.mode = this.blockMode; - return "ruleScoped"; -} - -function extendsStartMatch(match, index) { - this.remove.push({ - start: index, - length: 0 - }); - this.activeExtends = []; - return "extends"; -} - -function extendsEndMatch(match, index) { - var lastRemove = this.remove[this.remove.length - 1]; - lastRemove.length = index + match.length - lastRemove.start; - if(this.activeExtends.length === 0) { - errorMatch("expected class names")(match, index); - return "rule"; - } - return "firstRule"; -} - -function extendsClassNameMatch(match, name, index) { - this.activeSelectors.forEach(function(selector) { - if(!selector.extends) - selector.extends = []; - var extend = { - name: name, - start: index, - length: match.length, - from: null, - fromType: null - }; - selector.extends.push(extend); - this.activeExtends.push(extend); - }, this); -} - -function extendsFromUrlMatch(match, request) { - this.activeExtends.forEach(function(extend) { - extend.from = request; - extend.fromType = "url"; - }); -} - -function extendsFromMatch(match, request) { - this.activeExtends.forEach(function(extend) { - extend.from = loaderUtils.parseString(request); - extend.fromType = "module"; - }); -} - -var parser = new Parser({ - // shared stuff - comments: { - "/\\*[\\s\\S]*?\\*/": true - }, - strings: { - '"([^\\\\"]|\\\\.)*"': true, - "'([^\\\\']|\\\\.)*'": true - }, - urls: { - '(url\\s*\\()(\\s*"([^"]*)"\\s*)\\)': urlMatch, - "(url\\s*\\()(\\s*'([^']*)'\\s*)\\)": urlMatch, - "(url\\s*\\()(\\s*([^)]*)\\s*)\\)": urlMatch - }, - scopedRules: { - "(?:-[a-z]+-)?animation(?:-name)?:": ruleScopedMatch - }, - - // states - source: [ - "comments", - "strings", - "urls", - { - // imports - '@\\s*import\\s+"([^"]*)"\\s*([^;\\n]*);': importMatch, - "@\\s*import\\s+'([^'']*)'\\s*([^;\\n]*);": importMatch, - '@\\s*import\\s+url\\s*\\(\\s*"([^"]*)"\\s*\\)\\s*([^;\\n]*);': importMatch, - "@\\s*import\\s+url\\s*\\(\\s*'([^']*)'\\s*\\)\\s*([^;\\n]*);": importMatch, - "@\\s*import\\s+url\\s*\\(\\s*([^)]*)\\s*\\)\\s*([^;\\n]*);": importMatch, - - // charset - "@charset": true, - - // namespace - "@(?:-[a-z]+-)?namespace": true, - - // atrule - "@(?:-[a-z]+-)?keyframes": "atruleScoped", - "@": "atrule" - }, - { - // local - "(\\s*):local\\(": localStart, - "():local": enableLocal, - "(\\s+):local\\s+": enableLocal, - - // global - "(\\s*):global\\(": globalStart, - "():global": enableGlobal, - "(\\s+):global\\s+": enableGlobal, - - // class - "(\\.)(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)": selectorMatch, - - // id - "(#)(-?[_a-zA-Z]+[_a-zA-Z0-9-]*)": selectorMatch, - - // inside - "\\{": rulesStartMatch, - - ",": nextSelectorMatch - } - ], - atruleScoped: [ - "comments", - "strings", - { - // identifier - ":local\\(\\s*()([A-Za-z_\\-0-9]+)\\s*\\)": withMode("local", selectorMatch), - ":global\\(\\s*()([A-Za-z_\\-0-9]+)\\s*\\)": withMode("global", selectorMatch), - "()([A-Za-z_\\-0-9]+)": selectorMatch, - - // local - "():local": enableLocal, - "(\\s+):local\\s+": enableLocal, - - // global - "():global": enableGlobal, - "(\\s+):global\\s+": enableGlobal, - - // back to normal source - "\\{": "source" - } - ], - atrule: [ - "comments", - "strings", - { - // back to normal source - "\\{": "source" - } - ], - ruleScoped: [ - "comments", - { - // identifier - ":local\\(\\s*()([A-Za-z_\\-0-9]+)\\s*\\)": jump("ruleScopedInactive", withMode("local", selectorMatch)), - ":global\\(\\s*()([A-Za-z_\\-0-9]+)\\s*\\)": jump("ruleScopedInactive", withMode("global", selectorMatch)), - "()([A-Za-z_\\-0-9]+)": jump("ruleScopedInactive", selectorMatch), - - // local - "():local": enableLocal, - "(\\s+):local\\s+": enableLocal, - - // global - "():global": enableGlobal, - "(\\s+):global\\s+": enableGlobal, - - // back to normal rule - ";": "rule", - - // back to normal source - "\\}": rulesEndMatch - } - ], - ruleScopedInactive: [ - "comments", - { - // reactivate - ",": ruleScopedMatch, - - // back to normal rule - ";": "rule", - - // back to normal source - "\\}": rulesEndMatch - } - ], - rule: [ - "comments", - "strings", - "urls", - "scopedRules", - { - // back to normal source - "\\}": rulesEndMatch - } - ], - firstRule: [ - "rule", - { - "extends\\s*:": extendsStartMatch, - - // whitespace - "\\s+": true, - - // url - ".": "rule" - } - ], - extends: [ - "comments", - { - ";\\s*": extendsEndMatch, - - // whitespace - "\\s+": true, - - // from - "from": "extendsFrom", - - // class name - "([A-Za-z_\\-0-9]+)": extendsClassNameMatch, - - ".+[;}]": errorMatch("expected class names or 'from'", "rule"), - ".": errorMatch("expected class names or 'from'", "rule") - } - ], - extendsFrom: [ - "comments", - { - ";\\s*": extendsEndMatch, - - // whitespace - "\\s+": true, - - // module - 'url\\s*\\(\\s*"([^"]*)"\\s*\\)': extendsFromUrlMatch, - "url\\s*\\(\\s*'([^']*)'\\s*\\)": extendsFromUrlMatch, - "url\\s*\\(\\s*([^)]*)\\s*\\)": extendsFromUrlMatch, - '("(?:[^\\\\"]|\\\\.)*")': extendsFromMatch, - "('(?:[^\\\\']|\\\\.)*')": extendsFromMatch, - - ".+[;}]": errorMatch("expected module identifier (a string or 'url(...)'')", "rule"), - ".": errorMatch("expected module identifier (a string or 'url(...)'')", "rule") - } - ], - local: [ - "comments", - "strings", - { - // class - "(\\.)([A-Za-z_\\-0-9]+)": withMode("local", selectorMatch), - - // id - "(#)([A-Za-z_\\-0-9]+)": withMode("local", selectorMatch), - - // brackets - "\\(": innerBracketIn, - "\\)": innerBracketOut - } - ], - global: [ - "comments", - "strings", - { - // class - "(\\.)([A-Za-z_\\-0-9]+)": withMode("global", selectorMatch), - - // id - "(#)([A-Za-z_\\-0-9]+)": withMode("global", selectorMatch), - - // brackets - "\\(": innerBracketIn, - "\\)": innerBracketOut - } - ] -}); - -module.exports = function parseSource(source) { - var context = { - source: source, - imports: [], - urls: [], - selectors: [], - remove: [], - errors: [], - activeSelectors: [], - bracketStatus: 0, - mode: null - }; - return parser.parse("source", source, context); -}; diff --git a/lib/processCss.js b/lib/processCss.js new file mode 100644 index 00000000..6dbc5640 --- /dev/null +++ b/lib/processCss.js @@ -0,0 +1,191 @@ +/* + MIT License http://www.opensource.org/licenses/mit-license.php + Author Tobias Koppers @sokra +*/ +var Tokenizer = require("css-selector-tokenizer"); +var postcss = require("postcss"); +var loaderUtils = require("loader-utils"); +var getLocalIdent = require("./getLocalIdent"); + +var localByDefault = require("postcss-modules-local-by-default"); +var extractImports = require("postcss-modules-extract-imports"); +var modulesScope = require("postcss-modules-scope"); +var cssnano = require("cssnano"); + +var parserPlugin = postcss.plugin("css-loader-parser", function(options) { + return function(css) { + var imports = {}; + var exports = {}; + var importItems = []; + var urlItems = []; + + function replaceImportsInString(str) { + var tokens = str.split(/(\w+)/); + tokens = tokens.map(function(token) { + var importIndex = imports["$" + token]; + if(typeof importIndex === "number") { + return "___CSS_LOADER_IMPORT___" + importIndex + "___"; + } + return token; + }); + return tokens.join(""); + } + + css.eachAtRule("import", function(rule) { + var values = Tokenizer.parseValues(rule.params); + var url = values.nodes[0].nodes[0]; + if(url.type === "url") { + url = url.url; + } else if(url.type === "string") { + url = url.value; + } else throw rule.error("Unexpected format" + rule.params); + values.nodes[0].nodes.shift(); + var mediaQuery = Tokenizer.stringifyValues(values); + if(loaderUtils.isUrlRequest(url, options.root) && options.mode === "global") { + url = loaderUtils.urlToRequest(url, options.root); + } + importItems.push({ + url: url, + mediaQuery: mediaQuery + }); + rule.removeSelf(); + }); + + css.eachRule(function(rule) { + if(rule.selector === ":export") { + rule.eachDecl(function(decl) { + exports[decl.prop] = decl.value; + }); + rule.removeSelf(); + } else if(/^:import\(.+\)$/.test(rule.selector)) { + var match = /^:import\((.+)\)$/.exec(rule.selector); + var url = loaderUtils.parseString(match[1]); + rule.eachDecl(function(decl) { + imports["$" + decl.prop] = importItems.length; + importItems.push({ + url: url, + export: decl.value + }); + }); + rule.removeSelf(); + } + }); + + Object.keys(exports).forEach(function(exportName) { + exports[exportName] = replaceImportsInString(exports[exportName]); + }); + + css.eachDecl(function(decl) { + var values = Tokenizer.parseValues(decl.value); + values.nodes.forEach(function(value) { + value.nodes.forEach(function(item) { + switch(item.type) { + case "item": + var importIndex = imports["$" + item.name]; + if(typeof importIndex === "number") { + item.name = "___CSS_LOADER_IMPORT___" + importIndex + "___"; + } + break; + case "url": + if(!/^#/.test(item.url) && loaderUtils.isUrlRequest(item.url, options.root)) { + item.stringType = ""; + delete item.innerSpacingBefore; + delete item.innerSpacingAfter; + var url = item.url; + item.url = "___CSS_LOADER_URL___" + urlItems.length + "___"; + urlItems.push({ + url: url + }); + } + break; + } + }); + }); + decl.value = Tokenizer.stringifyValues(values); + }); + css.eachAtRule(function(atrule) { + if(typeof atrule.params === "string") { + atrule.params = replaceImportsInString(atrule.params); + } + }); + + options.importItems = importItems; + options.urlItems = urlItems; + options.exports = exports; + }; +}); + +module.exports = function processCss(inputSource, inputMap, options) { + + var query = options.query; + var root = query.root; + var localIdentName = query.localIdentName || "[hash:base64]"; + var localIdentRegExp = query.localIdentRegExp; + var forceMinimize = query.minimize; + var minimize = typeof forceMinimize !== "undefined" ? !!forceMinimize : options.minimize; + + + var parserOptions = { + root: root, + mode: options.mode + }; + + var pipeline = postcss([ + localByDefault({ + mode: options.mode, + rewriteUrl: function(global, url) { + if(!loaderUtils.isUrlRequest(url, root)) { + return url; + } + if(global) { + return loaderUtils.urlToRequest(url, root); + } + return url; + } + }), + extractImports({ + createImportedName: function(importName) { + return "___" + importName; + } + }), + modulesScope({ + generateScopedName: function(exportName) { + return getLocalIdent(options.loaderContext, localIdentName, exportName, { + regExp: localIdentRegExp + }); + } + }), + parserPlugin(parserOptions) + ]); + + if(minimize) { + pipeline.use(cssnano({ + zindex: false, + urls: false + })); + } + + var result = pipeline.process(inputSource, { + from: options.from, + to: options.to, + map: { + prev: inputMap, + sourcesContent: true, + inline: false, + annotation: false + } + }); + + + return { + source: result.css, + map: result.map && result.map.toJSON(), + exports: parserOptions.exports, + importItems: parserOptions.importItems, + importItemRegExpG: /___CSS_LOADER_IMPORT___([0-9]+)___/g, + importItemRegExp: /___CSS_LOADER_IMPORT___([0-9]+)___/, + urlItems: parserOptions.urlItems, + urlItemRegExpG: /___CSS_LOADER_URL___([0-9]+)___/g, + urlItemRegExp: /___CSS_LOADER_URL___([0-9]+)___/ + }; +}; diff --git a/lib/processLocals.js b/lib/processLocals.js deleted file mode 100644 index c98fc8c2..00000000 --- a/lib/processLocals.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - MIT License http://www.opensource.org/licenses/mit-license.php - Author Tobias Koppers @sokra -*/ -var getLocalIdent = require("./getLocalIdent"); -module.exports = function(parsedLocals, query, replacer, locals, localExtends) { - var localIdentName = query.localIdentName || "[hash:base64]"; - var localIdentRegExp = query.localIdentRegExp; - var moduleMode = query.module; - - parsedLocals.forEach(function(selector) { - if(moduleMode) { - if(selector.mode === "global") - return; - } else { - if(selector.mode !== "local") - return; - } - var ident; - var name = selector.name; - if(!locals[name]) { - ident = getLocalIdent(this, localIdentName, name, { - regExp: localIdentRegExp - }); - locals[name] = ident; - } else { - ident = locals[name]; - } - if(selector.extends) { - selector.extends.forEach(function(extend) { - if(!localExtends[name]) - localExtends[name] = []; - localExtends[name].push(extend); - }); - } - if(replacer) - replacer.replace(selector.start, selector.length, selector.prefix + ident); - }, this); -}; diff --git a/package.json b/package.json index 43e4f1d4..f8a06d85 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,13 @@ "author": "Tobias Koppers @sokra", "description": "css loader module for webpack", "dependencies": { - "clean-css": "^3.1.9", - "fastparse": "^1.1.1", + "css-selector-tokenizer": "^0.5.1", + "cssnano": "^1.4.2", "loader-utils": "~0.2.2", + "postcss": "^4.1.11", + "postcss-modules-extract-imports": "0.0.5", + "postcss-modules-local-by-default": "0.0.10", + "postcss-modules-scope": "0.0.7", "source-list-map": "^0.1.4" }, "devDependencies": { @@ -14,7 +18,7 @@ "coveralls": "^2.11.2", "istanbul": "^0.3.13", "mocha": "^2.2.4", - "should": "^5.2.0" + "should": "^7.0.1" }, "scripts": { "test": "mocha", diff --git a/test/helpers.js b/test/helpers.js index 59c065c1..1dd7bfee 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -2,14 +2,15 @@ require("should"); var cssLoader = require("../index.js"); +var cssLoaderLocals = require("../locals.js"); var vm = require("vm"); -function getEvaluated(output, result, modules) { +function getEvaluated(output, modules) { try { var fn = vm.runInThisContext("(function(module, exports, require) {" + output + "})", "testcase.js"); var m = { exports: {}, id: 1 }; fn(m, m.exports, function(module) { - if(module === "./lib/css-base.js") + if(module === require.resolve("../lib/css-base")) return require("../lib/css-base"); if(module.indexOf("-!loader!") === 0) module = module.substr(9); @@ -27,7 +28,7 @@ function getEvaluated(output, result, modules) { } function assetEvaluated(output, result, modules) { - var exports = getEvaluated(output, result, modules); + var exports = getEvaluated(output, modules); exports.should.be.eql(result); } @@ -51,6 +52,26 @@ exports.test = function test(name, input, result, query, modules) { }); }; +exports.testLocals = function testLocals(name, input, result, query, modules) { + it(name, function() { + var output = cssLoaderLocals.call({ + options: { + context: "" + }, + loaders: [{request: "loader"}], + loaderIndex: 0, + context: "", + resource: "test.css", + request: "css-loader/locals!test.css", + query: query, + emitError: function(message) { + throw new Error(message); + } + }, input); + assetEvaluated(output, result, modules); + }); +}; + exports.testSingleItem = function testSingleItem(name, input, result, query, modules) { it(name, function() { var output = cssLoader.call({ @@ -67,10 +88,10 @@ exports.testSingleItem = function testSingleItem(name, input, result, query, mod throw new Error(message); } }, input); - var exports = getEvaluated(output, result, modules); + var exports = getEvaluated(output, modules); Array.isArray(exports).should.be.eql(true); (exports.length).should.be.eql(1); - (exports[0].length).should.be.eql(3); + (exports[0].length >= 3).should.be.eql(true); (exports[0][0]).should.be.eql(1); (exports[0][2]).should.be.eql(""); (exports[0][1]).should.be.eql(result); diff --git a/test/importTest.js b/test/importTest.js index f50d2724..6314fffa 100644 --- a/test/importTest.js +++ b/test/importTest.js @@ -5,20 +5,26 @@ var test = require("./helpers").test; describe("import", function() { test("import", "@import url(test.css);\n.class { a: b c d; }", [ [2, ".test{a: b}", ""], - [1, "\n.class { a: b c d; }", ""] + [1, ".class { a: b c d; }", ""] + ], "", { + "./test.css": [[2, ".test{a: b}", ""]] + }); + test("import with string", "@import \"test.css\";\n.class { a: b c d; }", [ + [2, ".test{a: b}", ""], + [1, ".class { a: b c d; }", ""] ], "", { "./test.css": [[2, ".test{a: b}", ""]] }); test("import 2", "@import url('test.css');\n.class { a: b c d; }", [ [2, ".test{a: b}", "screen"], - [1, "\n.class { a: b c d; }", ""] + [1, ".class { a: b c d; }", ""] ], "", { "./test.css": [[2, ".test{a: b}", "screen"]] }); test("import with media", "@import url('~test/css') screen and print;\n.class { a: b c d; }", [ [3, ".test{a: b}", "((min-width: 100px)) and (screen and print)"], [2, ".test{c: d}", "screen and print"], - [1, "\n.class { a: b c d; }", ""] + [1, ".class { a: b c d; }", ""] ], "", { "test/css": [ [3, ".test{a: b}", "(min-width: 100px)"], @@ -28,6 +34,6 @@ describe("import", function() { test("import external", "@import url(http://example.com/style.css);\n@import url(\"//example.com/style.css\");", [ [1, "@import url(http://example.com/style.css);", ""], [1, "@import url(//example.com/style.css);", ""], - [1, "\n", ""] + [1, "", ""] ]); }); diff --git a/test/localTest.js b/test/localTest.js index 1fef236f..4e70e579 100644 --- a/test/localTest.js +++ b/test/localTest.js @@ -1,12 +1,18 @@ /*globals describe */ var test = require("./helpers").test; +var testMinimize = require("./helpers").testMinimize; function testLocal(name, input, result, localsResult, query, modules) { result.locals = localsResult; test(name, input, result, query, modules); } +function testLocalMinimize(name, input, result, localsResult, query, modules) { + result.locals = localsResult; + testMinimize(name, input, result, query, modules); +} + describe("local", function() { testLocal("locals-format", ":local(.test) { background: red; }", [ [1, ".test-3tNsp { background: red; }", ""] @@ -22,6 +28,13 @@ describe("local", function() { someId: "_1j3LM6lKkKzRIt19ImYVnD", subClass: "_13LGdX8RMStbBE9w-t0gZ1" }); + testLocalMinimize("minimized plus local", ":local(.localClass) { background: red; }\n:local .otherClass { background: red; }\n:local(.empty) { }", [ + [1, "._localClass,._otherClass{background:red}", ""] + ], { + localClass: "_localClass", + otherClass: "_otherClass", + empty: "_empty" + }, "?localIdentName=_[local]"); testLocal("mode switching", ".c1 :local .c2 .c3 :global .c4 :local .c5, .c6 :local .c7 { background: red; }\n.c8 { background: red; }", [ [1, ".c1 ._c2 ._c3 .c4 ._c5, .c6 ._c7 { background: red; }\n.c8 { background: red; }", ""] ], { @@ -31,13 +44,13 @@ describe("local", function() { c7: "_c7" }, "?localIdentName=_[local]"); testLocal("comment in local", ":local(.c1/*.c2*/.c3) { background: red; }", [ - [1, "._c1/*.c2*/._c3 { background: red; }", ""] + [1, "._c1._c3 { background: red; }", ""] ], { c1: "_c1", c3: "_c3" }, "?localIdentName=_[local]"); testLocal("comment in local", ":local(.c1/*.c2*/.c3) { background: red; }", [ - [1, "._c1/*.c2*/._c3 { background: red; }", ""] + [1, "._c1._c3 { background: red; }", ""] ], { c1: "_c1", c3: "_c3" @@ -50,16 +63,16 @@ describe("local", function() { c4: "_c4" }, "?localIdentName=_[local]"); - testLocal("extends class simple", ":local(.c1) { a: 1; }\n:local(.c2) { extends: c1; b: 1; }", [ + testLocal("composes class simple", ":local(.c1) { a: 1; }\n:local(.c2) { composes: c1; b: 1; }", [ [1, "._c1 { a: 1; }\n._c2 { b: 1; }", ""] ], { c1: "_c1", c2: "_c2 _c1" }, "?localIdentName=_[local]"); - testLocal("extends class from module", [ - ":local(.c1) { extends: c2 from \"./module\"; b: 1; }", - ":local(.c3) { extends: c1; b: 3; }", - ":local(.c5) { extends: c2 c4 from \"./module\"; b: 5; }" + testLocal("composes class from module", [ + ":local(.c1) { composes: c2 from \"./module\"; b: 1; }", + ":local(.c3) { composes: c1; b: 3; }", + ":local(.c5) { composes: c2 c4 from \"./module\"; b: 5; }" ].join("\n"), [ [2, ".test{c: d}", ""], [1, [ @@ -83,12 +96,12 @@ describe("local", function() { return r; }()) }); - testLocal("extends class from module with import", [ + testLocal("composes class from module with import", [ "@import url(\"module\");", - ":local(.c1) { extends: c2 c3 from \"./module\"; extends: c4 from url(module); b: 1; }" + ":local(.c1) { composes: c2 c3 from \"./module\"; composes: c4 from \"./module\"; b: 1; }" ].join("\n"), [ [2, ".test{c: d}", ""], - [1, "\n._c1 { b: 1; }", ""] + [1, "._c1 { b: 1; }", ""] ], { c1: "_c1 imported-c2 imported-c3 imported-c4" }, "?localIdentName=_[local]", { @@ -118,4 +131,16 @@ describe("local", function() { ], { "-a0-34a___f": "_1YJOcrkc6cyZmBAAvyPFOn" }, "?module"); + testLocal("imported values in decl", ".className { color: IMPORTED_NAME; }\n" + + ":import(\"./vars.css\") { IMPORTED_NAME: primary-color; }", [ + [1, "._className { color: red; }", ""] + ], { + "className": "_className" + }, "?module&localIdentName=_[local]", { + "./vars.css": { + locals: { + "primary-color": "red" + } + } + }); }); diff --git a/test/localsTest.js b/test/localsTest.js new file mode 100644 index 00000000..be155f06 --- /dev/null +++ b/test/localsTest.js @@ -0,0 +1,36 @@ +/*globals describe */ + +var testLocals = require("./helpers").testLocals; + +describe("locals", function() { + testLocals("should return only locals", + ".abc :local(.def) { color: red; } :local .ghi .jkl { color: blue; }", + { + def: "_def", + ghi: "_ghi", + jkl: "_jkl" + }, + "?localIdentName=_[local]" + ); + testLocals("should return only locals with composing", + ":local(.abc) { color: red; } :local(.def) { composes: abc; background: green; }", + { + abc: "_abc", + def: "_def _abc" + }, + "?localIdentName=_[local]" + ); + testLocals("should return only locals with importing", + ":local(.abc) { composes: def from \"./module.css\"; }", + { + abc: "_abc imported_def imported_ghi" + }, + "?localIdentName=_[local]", + { + "./module.css": { + def: "imported_def imported_ghi", + ghi: "imported_ghi" + } + } + ); +}); diff --git a/test/moduleTest.js b/test/moduleTest.js index ce9ff7fb..b5a56aac 100644 --- a/test/moduleTest.js +++ b/test/moduleTest.js @@ -12,6 +12,6 @@ describe("module", function() { var source = fs.readFileSync(path.join(testCasesPath, name, "source.css"), "utf-8"); var expected = fs.readFileSync(path.join(testCasesPath, name, "expected.css"), "utf-8"); - test(name, source, expected, "?module&localIdentName=_[local]_"); + test(name, source, expected, "?module&sourceMap&localIdentName=_[local]_"); }); }); diff --git a/test/moduleTestCases/leak-scope/expected.css b/test/moduleTestCases/leak-scope/expected.css index d91d3f85..2d72df97 100644 --- a/test/moduleTestCases/leak-scope/expected.css +++ b/test/moduleTestCases/leak-scope/expected.css @@ -18,8 +18,8 @@ } ._c_ { - animation: c1; - animation: _c2_, c3, _c4_; + animation: _c1_; + animation: _c2_, _c3_, _c4_; } @keyframes d { @@ -29,7 +29,7 @@ .d1 { animation: d1; - animation: d2, _d3_, d4; + animation: d2, d3, d4; } .d2 { diff --git a/test/moduleTestCases/leak-scope/source.css b/test/moduleTestCases/leak-scope/source.css index 75060bc8..586ef5e7 100644 --- a/test/moduleTestCases/leak-scope/source.css +++ b/test/moduleTestCases/leak-scope/source.css @@ -12,24 +12,24 @@ animation: b; } -@keyframes :global c { +@keyframes :global(c) { 0% { left: 10px; } 100% { left: 20px; } } .c { - animation: :global c1; - animation: c2, :global c3, c4; + animation: c1; + animation: c2, c3, c4; } -@keyframes :global d { +@keyframes :global(d) { 0% { left: 10px; } 100% { left: 20px; } } :global .d1 { animation: d1; - animation: d2, :local(d3), d4; + animation: d2, d3, d4; } :global(.d2) {