├── .gitignore ├── test ├── test-cases │ ├── export-with-composes │ │ ├── config.json │ │ ├── source.css │ │ └── expected.css │ ├── error-not-allowed-in-local │ │ ├── source.css │ │ └── expected.error.txt │ ├── export-class-path │ │ ├── config.json │ │ ├── source.css │ │ └── expected.css │ ├── export-with-global-composes │ │ ├── config.json │ │ ├── source.css │ │ └── expected.css │ ├── export-with-multiple-composes │ │ ├── config.json │ │ ├── source.css │ │ └── expected.css │ ├── error-comma-in-local │ │ ├── expected.error.txt │ │ └── source.css │ ├── export-global-class │ │ ├── options.js │ │ ├── source.css │ │ └── expected.css │ ├── export-global-id │ │ ├── options.js │ │ ├── source.css │ │ └── expected.css │ ├── export-with-transitive-composes │ │ ├── config.json │ │ ├── source.css │ │ └── expected.css │ ├── error-composes-css-nesting │ │ ├── expected.error.txt │ │ └── source.css │ ├── error-composes-not-allowed-in-simple │ │ ├── source.css │ │ └── expected.error.txt │ ├── error-multiple-nested-media │ │ ├── expected.error.txt │ │ └── source.css │ ├── export-with-composes-imported-class │ │ ├── config.json │ │ ├── source.css │ │ └── expected.css │ ├── error-composes-css-nesting-at-rule │ │ ├── expected.error.txt │ │ └── source.css │ ├── error-composes-css-nesting-with-media │ │ ├── expected.error.txt │ │ └── source.css │ ├── error-composes-not-allowed-in-local-id │ │ ├── source.css │ │ └── expected.error.txt │ ├── error-composes-not-allowed-in-wrong-local │ │ ├── source.css │ │ └── expected.error.txt │ ├── error-when-attribute-is-href │ │ ├── source.css │ │ └── expected.error.txt │ ├── error-when-attribute-is-type │ │ ├── source.css │ │ └── expected.error.txt │ ├── nested-rule │ │ ├── expected.css │ │ └── source.css │ ├── error-composes-not-allowed-in-multiple │ │ ├── source.css │ │ └── expected.error.txt │ ├── error-composes-not-defined-class │ │ ├── source.css │ │ └── expected.error.txt │ ├── error-when-attribute-is-target │ │ ├── source.css │ │ └── expected.error.txt │ ├── error-when-attribute-is-title │ │ ├── source.css │ │ └── expected.error.txt │ ├── export-child-class │ │ ├── source.css │ │ └── expected.css │ ├── export-keywords-selector │ │ ├── source.css │ │ └── expected.css │ ├── error-composes-keyframes │ │ ├── expected.error.txt │ │ └── source.css │ ├── ignore-custom-property-set │ │ ├── expected.css │ │ └── source.css │ ├── options-generateExportEntry │ │ ├── source.css │ │ ├── options.js │ │ └── expected.css │ ├── options-generateScopedName │ │ ├── source.css │ │ ├── options.js │ │ └── expected.css │ ├── export-multiple-classes │ │ ├── source.css │ │ └── expected.css │ ├── nothing │ │ ├── source.css │ │ └── expected.css │ ├── at-rule │ │ ├── source.css │ │ └── expected.css │ ├── export-class-attribute │ │ ├── source.css │ │ └── expected.css │ ├── export-nested-class │ │ ├── source.css │ │ └── expected.css │ ├── composes-only-allowed │ │ ├── source.css │ │ └── expected.css │ ├── css-nesting-composes │ │ ├── source.css │ │ └── expected.css │ ├── multiple-composes │ │ ├── source.css │ │ └── expected.css │ ├── export-difficult │ │ ├── source.css │ │ └── expected.css │ ├── export-keyframes │ │ ├── source.css │ │ └── expected.css │ ├── css-nesting │ │ ├── source.css │ │ └── expected.css │ ├── at-rule-scope │ │ ├── source.css │ │ └── expected.css │ └── escape-sequence │ │ ├── options.js │ │ ├── expected.css │ │ └── source.css └── cases.test.js ├── husky.config.js ├── .travis.yml ├── lint-staged.config.js ├── .eslintrc ├── .editorconfig ├── LICENSE ├── package.json ├── README.md ├── CHANGELOG.md └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | .nyc_output 4 | coverage 5 | node_modules 6 | -------------------------------------------------------------------------------- /test/test-cases/export-with-composes/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "from": "/lib/extender.css" 3 | } -------------------------------------------------------------------------------- /test/test-cases/error-not-allowed-in-local/source.css: -------------------------------------------------------------------------------- 1 | :local(body) { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/export-class-path/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "from": "/lib/components/button.css" 3 | } -------------------------------------------------------------------------------- /test/test-cases/export-class-path/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName) { 2 | color: green; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/export-with-global-composes/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "from": "/lib/extender.css" 3 | } -------------------------------------------------------------------------------- /test/test-cases/export-with-multiple-composes/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "from": "/lib/extender.css" 3 | } -------------------------------------------------------------------------------- /test/test-cases/error-comma-in-local/expected.error.txt: -------------------------------------------------------------------------------- 1 | Unexpected comma \(","\) in :local block 2 | -------------------------------------------------------------------------------- /test/test-cases/error-comma-in-local/source.css: -------------------------------------------------------------------------------- 1 | :local(.a, .b) { 2 | composes: className; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/export-global-class/options.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | exportGlobals: true, 3 | }; 4 | -------------------------------------------------------------------------------- /test/test-cases/export-global-id/options.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | exportGlobals: true, 3 | }; 4 | -------------------------------------------------------------------------------- /test/test-cases/export-with-transitive-composes/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "from": "/lib/extender.css" 3 | } -------------------------------------------------------------------------------- /husky.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | "pre-commit": "lint-staged", 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-css-nesting/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is not allowed in nested rule 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-simple/source.css: -------------------------------------------------------------------------------- 1 | body { 2 | composes: className; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-multiple-nested-media/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is not allowed in nested rule 2 | -------------------------------------------------------------------------------- /test/test-cases/export-with-composes-imported-class/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "from": "/lib/extender.css" 3 | } -------------------------------------------------------------------------------- /test/test-cases/error-not-allowed-in-local/expected.error.txt: -------------------------------------------------------------------------------- 1 | tag \("body"\) is not allowed in a :local block 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-css-nesting-at-rule/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is not allowed in nested rule 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-css-nesting-with-media/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is not allowed in nested rule 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-local-id/source.css: -------------------------------------------------------------------------------- 1 | :local(#idName) { 2 | composes: className; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-wrong-local/source.css: -------------------------------------------------------------------------------- 1 | :local(.a.b) { 2 | composes: className; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-href/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName1[href^="https"]) { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-type/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName1[type="text"]) { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/nested-rule/expected.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --test: { 3 | --test: foo; 4 | --bar: 1; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/test-cases/nested-rule/source.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --test: { 3 | --test: foo; 4 | --bar: 1; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-multiple/source.css: -------------------------------------------------------------------------------- 1 | :local(.a) :local(.b) { 2 | composes: className; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-defined-class/source.css: -------------------------------------------------------------------------------- 1 | :local(.className) { 2 | compose-with: otherClassName; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-target/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName1[target="_blank"]) { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-title/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName1[title="flower"]) { 2 | color: blue; 3 | } 4 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-type/expected.error.txt: -------------------------------------------------------------------------------- 1 | attribute \("\[type\="text"]\"\) is not allowed in a :local block 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-defined-class/expected.error.txt: -------------------------------------------------------------------------------- 1 | referenced class name "otherClassName" in compose-with not found 2 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-href/expected.error.txt: -------------------------------------------------------------------------------- 1 | attribute \("\[href\^="https"]\"\) is not allowed in a :local block 2 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-title/expected.error.txt: -------------------------------------------------------------------------------- 1 | attribute \("\[title\="flower"]\"\) is not allowed in a :local block 2 | -------------------------------------------------------------------------------- /test/test-cases/error-when-attribute-is-target/expected.error.txt: -------------------------------------------------------------------------------- 1 | attribute \("\[target\="_blank"]\"\) is not allowed in a :local block 2 | -------------------------------------------------------------------------------- /test/test-cases/export-child-class/source.css: -------------------------------------------------------------------------------- 1 | :local(.simple) { 2 | color: red; 3 | } 4 | 5 | :local(.simple) h1 { 6 | color: blue; 7 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "10" 5 | - "12" 6 | - "14" 7 | 8 | script: yarn test && coveralls < coverage/lcov.info 9 | -------------------------------------------------------------------------------- /test/test-cases/export-keywords-selector/source.css: -------------------------------------------------------------------------------- 1 | :local(.constructor) { 2 | color: green; 3 | } 4 | 5 | :local(.toString) { 6 | color: red; 7 | } -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.js": ["eslint --fix", "prettier --write"], 3 | "*.{json,md,yml,css,ts}": ["prettier --write"], 4 | }; 5 | -------------------------------------------------------------------------------- /test/test-cases/export-with-composes/source.css: -------------------------------------------------------------------------------- 1 | :local(.otherClass) { background: red; } :local(.exportName) { compose-with: otherClass; color: green; } 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-keyframes/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is only allowed when selector is single :local class name not in "to", "to" is weird 2 | -------------------------------------------------------------------------------- /test/test-cases/ignore-custom-property-set/expected.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --title-align: center; 3 | --sr-only: { 4 | position: absolute; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/test-cases/ignore-custom-property-set/source.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --title-align: center; 3 | --sr-only: { 4 | position: absolute; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/test-cases/options-generateExportEntry/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName) { 2 | color: green; 3 | } 4 | 5 | :local(.exportName):hover { 6 | color: red; 7 | } 8 | -------------------------------------------------------------------------------- /test/test-cases/options-generateScopedName/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName) { 2 | color: green; 3 | } 4 | 5 | :local(.exportName):hover { 6 | color: red; 7 | } 8 | -------------------------------------------------------------------------------- /test/test-cases/options-generateScopedName/options.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | generateScopedName: function (name) { 3 | return "_" + name + "_"; 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-simple/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is only allowed when selector is single :local class name not in "body", "body" is weird 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-css-nesting/source.css: -------------------------------------------------------------------------------- 1 | :local(.otherClassName) { 2 | } 3 | 4 | :local(.a) { 5 | :local(.b) { 6 | compose-with: otherClassName; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-multiple/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is only allowed when selector is single :local class name not in ":local\(\.a\) :local\(\.b\)" 2 | -------------------------------------------------------------------------------- /test/test-cases/export-class-path/expected.css: -------------------------------------------------------------------------------- 1 | ._lib_components_button__exportName { 2 | color: green; 3 | } 4 | :export { 5 | exportName: _lib_components_button__exportName; 6 | } 7 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-local-id/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is only allowed when selector is single :local class name not in ":local\(#idName\)", "#idName" is weird 2 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-not-allowed-in-wrong-local/expected.error.txt: -------------------------------------------------------------------------------- 1 | composition is only allowed when selector is single :local class name not in ":local\(\.a\.b\)", "\.a\.b" is weird 2 | -------------------------------------------------------------------------------- /test/test-cases/export-multiple-classes/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName) :local(.otherExport) { 2 | color: green; 3 | } 4 | 5 | :local(.exportName):local(.otherExport) { 6 | color: red; 7 | } 8 | -------------------------------------------------------------------------------- /test/test-cases/export-child-class/expected.css: -------------------------------------------------------------------------------- 1 | ._input__simple { 2 | color: red; 3 | } 4 | 5 | ._input__simple h1 { 6 | color: blue; 7 | } 8 | 9 | :export { 10 | simple: _input__simple; 11 | } 12 | -------------------------------------------------------------------------------- /test/test-cases/nothing/source.css: -------------------------------------------------------------------------------- 1 | .exportName { 2 | color: green; 3 | } 4 | 5 | .exportName:hover { 6 | color: red; 7 | } 8 | 9 | @media screen { 10 | body { 11 | background: red; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-keyframes/source.css: -------------------------------------------------------------------------------- 1 | :local(.bar) { 2 | } 3 | 4 | @keyframes slidein { 5 | from { 6 | transform: translateX(0%); 7 | } 8 | 9 | to { 10 | composes: bar; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/test-cases/export-with-composes-imported-class/source.css: -------------------------------------------------------------------------------- 1 | :import("./file.css") { 2 | imported_otherClass: otherClass; 3 | } 4 | :local(.exportName) { 5 | composes: imported_otherClass; 6 | color: green; 7 | } 8 | -------------------------------------------------------------------------------- /test/test-cases/nothing/expected.css: -------------------------------------------------------------------------------- 1 | .exportName { 2 | color: green; 3 | } 4 | 5 | .exportName:hover { 6 | color: red; 7 | } 8 | 9 | @media screen { 10 | body { 11 | background: red; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/test-cases/options-generateScopedName/expected.css: -------------------------------------------------------------------------------- 1 | ._exportName_ { 2 | color: green; 3 | } 4 | 5 | ._exportName_:hover { 6 | color: red; 7 | } 8 | 9 | :export { 10 | exportName: _exportName_; 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2018 4 | }, 5 | "env": { 6 | "es6": true, 7 | "node": true, 8 | "jest": true 9 | }, 10 | "extends": ["eslint:recommended", "prettier"] 11 | } 12 | -------------------------------------------------------------------------------- /test/test-cases/at-rule/source.css: -------------------------------------------------------------------------------- 1 | :local(.otherClass) { 2 | background: red; 3 | } 4 | 5 | @media screen { 6 | :local(.foo) { 7 | color: green; 8 | :local(.baz) { 9 | color: blue; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/test-cases/options-generateExportEntry/options.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | generateExportEntry: function(name, scopedName) { 3 | return { 4 | key: `_${name}_`, 5 | value: `_${scopedName}_` 6 | } 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /test/test-cases/export-class-attribute/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName1) { 2 | color: red; 3 | } 4 | 5 | :local(.exportName2) { 6 | color: green; 7 | } 8 | 9 | :local(.exportName2[class="exportName1"]) { 10 | color: blue; 11 | } 12 | -------------------------------------------------------------------------------- /test/test-cases/export-nested-class/source.css: -------------------------------------------------------------------------------- 1 | :local(.exportName):not(:local(.otherExportName).global) { 2 | color: green; 3 | } 4 | 5 | :local(.exportName):has(:local(.otherExportName), :local(.otherExportName2)) { 6 | color: red; 7 | } 8 | -------------------------------------------------------------------------------- /test/test-cases/options-generateExportEntry/expected.css: -------------------------------------------------------------------------------- 1 | ._input__exportName { 2 | color: green; 3 | } 4 | 5 | ._input__exportName:hover { 6 | color: red; 7 | } 8 | 9 | :export { 10 | _exportName_: __input__exportName_; 11 | } 12 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-css-nesting-at-rule/source.css: -------------------------------------------------------------------------------- 1 | :local(.otherClassName) { 2 | } 3 | 4 | @media (min-width: 1024px) { 5 | :local(.a) { 6 | :local(.b) { 7 | compose-with: otherClassName; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/test-cases/error-composes-css-nesting-with-media/source.css: -------------------------------------------------------------------------------- 1 | :local(.otherClassName) { 2 | } 3 | 4 | :local(.a) { 5 | @media (min-width: 1024px) { 6 | :local(.b) { 7 | compose-with: otherClassName; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/test-cases/composes-only-allowed/source.css: -------------------------------------------------------------------------------- 1 | :local(.class) { 2 | composes: global(a); 3 | compose-with: global(b); 4 | a-composes: global(c); 5 | composes-b: global(d); 6 | a-composes-b: global(e); 7 | a-compose-with-b: global(b); 8 | } 9 | -------------------------------------------------------------------------------- /test/test-cases/composes-only-allowed/expected.css: -------------------------------------------------------------------------------- 1 | ._input__class { 2 | a-composes: global(c); 3 | composes-b: global(d); 4 | a-composes-b: global(e); 5 | a-compose-with-b: global(b); 6 | } 7 | :export { 8 | class: _input__class a b; 9 | } 10 | -------------------------------------------------------------------------------- /test/test-cases/css-nesting-composes/source.css: -------------------------------------------------------------------------------- 1 | :local(.bar) { 2 | color: red; 3 | } 4 | 5 | :local(.foo) { 6 | display: grid; 7 | composes: bar; 8 | 9 | @media (orientation: landscape) { 10 | grid-auto-flow: column; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/test-cases/export-keywords-selector/expected.css: -------------------------------------------------------------------------------- 1 | ._input__constructor { 2 | color: green; 3 | } 4 | 5 | ._input__toString { 6 | color: red; 7 | } 8 | 9 | :export { 10 | constructor: _input__constructor; 11 | toString: _input__toString; 12 | } 13 | -------------------------------------------------------------------------------- /test/test-cases/export-with-composes/expected.css: -------------------------------------------------------------------------------- 1 | ._lib_extender__otherClass { background: red; } ._lib_extender__exportName { color: green; } :export { 2 | otherClass: _lib_extender__otherClass; 3 | exportName: _lib_extender__exportName _lib_extender__otherClass; } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /test/test-cases/export-with-global-composes/source.css: -------------------------------------------------------------------------------- 1 | .otherClass { background: red; } 2 | .andAgain { font-size: 2em; } 3 | .aThirdClass { color: red; } 4 | :local(.exportName) { compose-with: global(otherClass) global(andAgain); compose-with: global(aThirdClass); color: green; } 5 | -------------------------------------------------------------------------------- /test/test-cases/export-with-multiple-composes/source.css: -------------------------------------------------------------------------------- 1 | :local(.otherClass) { background: red; } 2 | :local(.andAgain) { font-size: 2em; } 3 | :local(.aThirdClass) { color: red; } 4 | :local(.exportName) { compose-with: otherClass andAgain; compose-with: aThirdClass; color: green; } 5 | -------------------------------------------------------------------------------- /test/test-cases/export-global-class/source.css: -------------------------------------------------------------------------------- 1 | .exportName { 2 | color: green; 3 | } 4 | 5 | .exportName:hover { 6 | color: red; 7 | } 8 | 9 | @media screen { 10 | body { 11 | background: red; 12 | } 13 | } 14 | 15 | :local(.testLocal) { 16 | color: blue; 17 | } 18 | -------------------------------------------------------------------------------- /test/test-cases/export-with-composes-imported-class/expected.css: -------------------------------------------------------------------------------- 1 | :import("./file.css") { 2 | imported_otherClass: otherClass; 3 | } 4 | ._lib_extender__exportName { 5 | color: green; 6 | } 7 | :export { 8 | exportName: _lib_extender__exportName imported_otherClass; 9 | } 10 | -------------------------------------------------------------------------------- /test/test-cases/export-with-transitive-composes/source.css: -------------------------------------------------------------------------------- 1 | :local(.aThirdClass) { 2 | font-size: 2em; 3 | } 4 | :local(.otherClass) { 5 | composes: aThirdClass; 6 | background: red; 7 | } 8 | :local(.exportName) { 9 | composes: otherClass; 10 | color: green; 11 | } 12 | -------------------------------------------------------------------------------- /test/test-cases/multiple-composes/source.css: -------------------------------------------------------------------------------- 1 | :import("path") { 2 | i__i_a_0: a; 3 | i__i_b_0: b; 4 | i__i_c_0: c; 5 | i__i_d_0: d; 6 | } 7 | :local(.class) { 8 | composes: i__i_a_0 i__i_b_0, i__i_c_0, global(d) global(e), global(f), i__i_d_0; 9 | color: red; 10 | } 11 | -------------------------------------------------------------------------------- /test/test-cases/multiple-composes/expected.css: -------------------------------------------------------------------------------- 1 | :import("path") { 2 | i__i_a_0: a; 3 | i__i_b_0: b; 4 | i__i_c_0: c; 5 | i__i_d_0: d; 6 | } 7 | ._input__class { 8 | color: red; 9 | } 10 | :export { 11 | class: _input__class i__i_a_0 i__i_b_0 i__i_c_0 d e f i__i_d_0; 12 | } 13 | -------------------------------------------------------------------------------- /test/test-cases/export-global-id/source.css: -------------------------------------------------------------------------------- 1 | #exportName { 2 | color: green; 3 | } 4 | 5 | #exportName:hover { 6 | color: red; 7 | } 8 | 9 | @media screen { 10 | #exportName-2 { 11 | background: red; 12 | } 13 | } 14 | 15 | :local(#exportName-3) { 16 | color: green; 17 | } 18 | -------------------------------------------------------------------------------- /test/test-cases/export-with-global-composes/expected.css: -------------------------------------------------------------------------------- 1 | .otherClass { background: red; } 2 | .andAgain { font-size: 2em; } 3 | .aThirdClass { color: red; } 4 | ._lib_extender__exportName { color: green; } 5 | :export { 6 | exportName: _lib_extender__exportName otherClass andAgain aThirdClass; } 7 | -------------------------------------------------------------------------------- /test/test-cases/export-multiple-classes/expected.css: -------------------------------------------------------------------------------- 1 | ._input__exportName ._input__otherExport { 2 | color: green; 3 | } 4 | 5 | ._input__exportName._input__otherExport { 6 | color: red; 7 | } 8 | 9 | :export { 10 | exportName: _input__exportName; 11 | otherExport: _input__otherExport; 12 | } 13 | -------------------------------------------------------------------------------- /test/test-cases/error-multiple-nested-media/source.css: -------------------------------------------------------------------------------- 1 | :local(.bar) { 2 | color: blue; 3 | } 4 | 5 | :local(.foo) { 6 | display: grid; 7 | 8 | @media (orientation: landscape) { 9 | grid-auto-flow: column; 10 | 11 | @media (min-width: 1024px) { 12 | composes: bar; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/test-cases/css-nesting-composes/expected.css: -------------------------------------------------------------------------------- 1 | ._input__bar { 2 | color: red; 3 | } 4 | 5 | ._input__foo { 6 | display: grid; 7 | 8 | @media (orientation: landscape) { 9 | grid-auto-flow: column; 10 | } 11 | } 12 | 13 | :export { 14 | bar: _input__bar; 15 | foo: _input__foo _input__bar; 16 | } 17 | -------------------------------------------------------------------------------- /test/test-cases/at-rule/expected.css: -------------------------------------------------------------------------------- 1 | ._input__otherClass { 2 | background: red; 3 | } 4 | 5 | @media screen { 6 | ._input__foo { 7 | color: green; 8 | ._input__baz { 9 | color: blue; 10 | } 11 | } 12 | } 13 | 14 | :export { 15 | otherClass: _input__otherClass; 16 | foo: _input__foo; 17 | baz: _input__baz; 18 | } 19 | -------------------------------------------------------------------------------- /test/test-cases/export-class-attribute/expected.css: -------------------------------------------------------------------------------- 1 | ._input__exportName1 { 2 | color: red; 3 | } 4 | 5 | ._input__exportName2 { 6 | color: green; 7 | } 8 | 9 | ._input__exportName2[class=_input__exportName1] { 10 | color: blue; 11 | } 12 | 13 | :export { 14 | exportName1: _input__exportName1; 15 | exportName2: _input__exportName2; 16 | } 17 | -------------------------------------------------------------------------------- /test/test-cases/export-global-class/expected.css: -------------------------------------------------------------------------------- 1 | .exportName { 2 | color: green; 3 | } 4 | 5 | .exportName:hover { 6 | color: red; 7 | } 8 | 9 | @media screen { 10 | body { 11 | background: red; 12 | } 13 | } 14 | 15 | ._input__testLocal { 16 | color: blue; 17 | } 18 | 19 | :export { 20 | exportName: exportName; 21 | testLocal: _input__testLocal; 22 | } 23 | -------------------------------------------------------------------------------- /test/test-cases/export-nested-class/expected.css: -------------------------------------------------------------------------------- 1 | ._input__exportName:not(._input__otherExportName.global) { 2 | color: green; 3 | } 4 | 5 | ._input__exportName:has(._input__otherExportName, ._input__otherExportName2) { 6 | color: red; 7 | } 8 | 9 | :export { 10 | exportName: _input__exportName; 11 | otherExportName: _input__otherExportName; 12 | otherExportName2: _input__otherExportName2; 13 | } 14 | -------------------------------------------------------------------------------- /test/test-cases/export-global-id/expected.css: -------------------------------------------------------------------------------- 1 | #exportName { 2 | color: green; 3 | } 4 | 5 | #exportName:hover { 6 | color: red; 7 | } 8 | 9 | @media screen { 10 | #exportName-2 { 11 | background: red; 12 | } 13 | } 14 | 15 | #_input__exportName-3 { 16 | color: green; 17 | } 18 | 19 | :export { 20 | exportName: exportName; 21 | exportName-2: exportName-2; 22 | exportName-3: _input__exportName-3; 23 | } 24 | -------------------------------------------------------------------------------- /test/test-cases/export-difficult/source.css: -------------------------------------------------------------------------------- 1 | @keyframes :local(fade-in) { 2 | from { 3 | opacity: 0; 4 | } 5 | } 6 | 7 | @-webkit-keyframes :local(fade-out) { 8 | to { 9 | opacity: 0; 10 | } 11 | } 12 | 13 | :local(.fadeIn) { 14 | animation: _colon_local(fade-in) 5s,/* some, :local(comment) */ 15 | _colon_local(fade-out) 1s _colon_local(wrong); 16 | content: _colon_local(fade-in), wrong, "difficult, :local(wrong)" _colon_local(wrong); 17 | } 18 | -------------------------------------------------------------------------------- /test/test-cases/export-keyframes/source.css: -------------------------------------------------------------------------------- 1 | @keyframes :local(fade-in) { 2 | from { 3 | opacity: 0; 4 | } 5 | 100% { 6 | opacity: 1; 7 | } 8 | } 9 | 10 | @keyframes fade { 11 | from { 12 | opacity: 0.5; 13 | } 14 | } 15 | 16 | :local(.fadeIn) { 17 | animation-name: _colon_local(fade-in); 18 | } 19 | 20 | :local(.fadeIn) { 21 | animation: 2s _colon_local(fade-in); 22 | } 23 | 24 | :local(.fadeIn) { 25 | animation: _colon_local(fade-in) 2s; 26 | } -------------------------------------------------------------------------------- /test/test-cases/css-nesting/source.css: -------------------------------------------------------------------------------- 1 | :local(.otherClass) { 2 | background: red; 3 | } 4 | 5 | :local(.foo) { 6 | color: green; 7 | 8 | @media (max-width: 520px) { 9 | :local(.bar) { 10 | color: darkgreen; 11 | } 12 | 13 | &:local(.baz) { 14 | color: blue; 15 | } 16 | } 17 | } 18 | 19 | :local(.a) { 20 | color: red; 21 | 22 | &:local(.b) { 23 | color: green; 24 | } 25 | 26 | :local(.c) { 27 | color: blue; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/test-cases/export-with-transitive-composes/expected.css: -------------------------------------------------------------------------------- 1 | ._lib_extender__aThirdClass { 2 | font-size: 2em; 3 | } 4 | ._lib_extender__otherClass { 5 | background: red; 6 | } 7 | ._lib_extender__exportName { 8 | color: green; 9 | } 10 | :export { 11 | aThirdClass: _lib_extender__aThirdClass; 12 | otherClass: _lib_extender__otherClass _lib_extender__aThirdClass; 13 | exportName: _lib_extender__exportName _lib_extender__otherClass _lib_extender__aThirdClass; 14 | } 15 | -------------------------------------------------------------------------------- /test/test-cases/export-with-multiple-composes/expected.css: -------------------------------------------------------------------------------- 1 | ._lib_extender__otherClass { background: red; } 2 | ._lib_extender__andAgain { font-size: 2em; } 3 | ._lib_extender__aThirdClass { color: red; } 4 | ._lib_extender__exportName { color: green; } 5 | :export { 6 | otherClass: _lib_extender__otherClass; 7 | andAgain: _lib_extender__andAgain; 8 | aThirdClass: _lib_extender__aThirdClass; 9 | exportName: _lib_extender__exportName _lib_extender__otherClass _lib_extender__andAgain _lib_extender__aThirdClass; } -------------------------------------------------------------------------------- /test/test-cases/export-difficult/expected.css: -------------------------------------------------------------------------------- 1 | @keyframes _input__fade-in { 2 | from { 3 | opacity: 0; 4 | } 5 | } 6 | 7 | @-webkit-keyframes _input__fade-out { 8 | to { 9 | opacity: 0; 10 | } 11 | } 12 | 13 | ._input__fadeIn { 14 | animation: _input__fade-in 5s, 15 | _input__fade-out 1s :local(wrong); 16 | content: _input__fade-in, wrong, "difficult, :local(wrong)" :local(wrong); 17 | } 18 | 19 | :export { 20 | fadeIn: _input__fadeIn; 21 | fade-in: _input__fade-in; 22 | fade-out: _input__fade-out; 23 | } 24 | -------------------------------------------------------------------------------- /test/test-cases/export-keyframes/expected.css: -------------------------------------------------------------------------------- 1 | @keyframes _input__fade-in { 2 | from { 3 | opacity: 0; 4 | } 5 | 100% { 6 | opacity: 1; 7 | } 8 | } 9 | 10 | @keyframes fade { 11 | from { 12 | opacity: 0.5; 13 | } 14 | } 15 | 16 | ._input__fadeIn { 17 | animation-name: _input__fade-in; 18 | } 19 | 20 | ._input__fadeIn { 21 | animation: 2s _input__fade-in; 22 | } 23 | 24 | ._input__fadeIn { 25 | animation: _input__fade-in 2s; 26 | } 27 | 28 | :export { 29 | fadeIn: _input__fadeIn; 30 | fade-in: _input__fade-in; 31 | } 32 | -------------------------------------------------------------------------------- /test/test-cases/at-rule-scope/source.css: -------------------------------------------------------------------------------- 1 | :local(.d) { 2 | color: red; 3 | } 4 | 5 | @scope (:local(.a)) to (:local(.b)) { 6 | :local(.c) { 7 | border: 5px solid black; 8 | background-color: goldenrod; 9 | } 10 | } 11 | 12 | @scope (:local(.a)) { 13 | :local(.e) { 14 | border: 5px solid black; 15 | } 16 | } 17 | 18 | @scope (:local(.a)) to (img) { 19 | :local(.f) { 20 | background-color: goldenrod; 21 | } 22 | } 23 | 24 | @scope (:local(.g)) { 25 | img { 26 | backdrop-filter: blur(2px); 27 | } 28 | } 29 | 30 | @scope { 31 | :scope { 32 | color: red; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/test-cases/css-nesting/expected.css: -------------------------------------------------------------------------------- 1 | ._input__otherClass { 2 | background: red; 3 | } 4 | 5 | ._input__foo { 6 | color: green; 7 | 8 | @media (max-width: 520px) { 9 | ._input__bar { 10 | color: darkgreen; 11 | } 12 | 13 | &._input__baz { 14 | color: blue; 15 | } 16 | } 17 | } 18 | 19 | ._input__a { 20 | color: red; 21 | 22 | &._input__b { 23 | color: green; 24 | } 25 | 26 | ._input__c { 27 | color: blue; 28 | } 29 | } 30 | 31 | :export { 32 | otherClass: _input__otherClass; 33 | foo: _input__foo; 34 | bar: _input__bar; 35 | baz: _input__baz; 36 | a: _input__a; 37 | b: _input__b; 38 | c: _input__c; 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | 3 | Copyright (c) 2015, Glen Maddern 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /test/test-cases/at-rule-scope/expected.css: -------------------------------------------------------------------------------- 1 | ._input__d { 2 | color: red; 3 | } 4 | 5 | @scope (._input__a) to (._input__b) { 6 | ._input__c { 7 | border: 5px solid black; 8 | background-color: goldenrod; 9 | } 10 | } 11 | 12 | @scope (._input__a) { 13 | ._input__e { 14 | border: 5px solid black; 15 | } 16 | } 17 | 18 | @scope (._input__a) to (img) { 19 | ._input__f { 20 | background-color: goldenrod; 21 | } 22 | } 23 | 24 | @scope (._input__g) { 25 | img { 26 | backdrop-filter: blur(2px); 27 | } 28 | } 29 | 30 | @scope { 31 | :scope { 32 | color: red; 33 | } 34 | } 35 | 36 | :export { 37 | d: _input__d; 38 | c: _input__c; 39 | e: _input__e; 40 | f: _input__f; 41 | a: _input__a; 42 | b: _input__b; 43 | g: _input__g; 44 | } 45 | -------------------------------------------------------------------------------- /test/test-cases/escape-sequence/options.js: -------------------------------------------------------------------------------- 1 | const cssesc = require('cssesc'); 2 | 3 | //eslint-disable-next-line no-control-regex 4 | const filenameReservedRegex = /[<>:"/\\|?*\x00-\x1F]/g; 5 | //eslint-disable-next-line no-control-regex 6 | const reControlChars = /[\u0000-\u001f\u0080-\u009f]/g; 7 | const reRelativePath = /^\.+/; 8 | 9 | module.exports = { 10 | generateScopedName: function(name) { 11 | return cssesc( 12 | name 13 | .replace(/smile/, '😀') 14 | .replace(/_with_A/g, 'A') 15 | .replace(/^((-?[0-9])|--)/, '_$1') 16 | .replace(filenameReservedRegex, '-') 17 | .replace(reControlChars, '-') 18 | .replace(reRelativePath, '-') 19 | .replace(/\./g, '-'), 20 | { isIdentifier: true } 21 | ); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /test/test-cases/escape-sequence/expected.css: -------------------------------------------------------------------------------- 1 | .\1F600 { 2 | color: red; 3 | } 4 | 5 | .\1F600 .\1F600 { 6 | color: red; 7 | } 8 | 9 | .\1F600 .\1F600 .\1F600 { 10 | color: red; 11 | } 12 | 13 | .\1F600 A { 14 | color: red; 15 | } 16 | 17 | .\1F600 .\1F600 { 18 | color: red; 19 | } 20 | 21 | .\1F600 .\1F600 { 22 | color: red; 23 | } 24 | 25 | .\1F600 .\1F600 .\1F600 { 26 | color: red; 27 | } 28 | 29 | .\1F600 .\1F600 A .\1F600 { 30 | color: red; 31 | } 32 | 33 | #\1F600 #\1F600 #\1F600 { 34 | color: red; 35 | } 36 | 37 | #\1F600 #\1F600 A #\1F600 { 38 | color: red; 39 | } 40 | 41 | .a .\1F600 b { 42 | color: red; 43 | } 44 | 45 | .\1F600 > .\1F600 > .\1F600 { 46 | color: red; 47 | } 48 | 49 | .\1F600 .\1F600 { 50 | color: red; 51 | } 52 | 53 | .\1F600.\1F600 { 54 | color: red; 55 | } 56 | 57 | .\1F600 .\1F600 { 58 | color: red; 59 | } 60 | 61 | .\1F600 .a { 62 | color: red; 63 | } 64 | 65 | .\1F600.a { 66 | color: red; 67 | } 68 | 69 | .a .\1F600 { 70 | color: red; 71 | } 72 | 73 | .a.\1F600 { 74 | color: red; 75 | } 76 | 77 | :export { 78 | smile: 😀; 79 | smile_with_A: 😀A; 80 | } 81 | -------------------------------------------------------------------------------- /test/test-cases/escape-sequence/source.css: -------------------------------------------------------------------------------- 1 | :local(.smile) { 2 | color: red; 3 | } 4 | 5 | :local(.smile) :local(.smile) { 6 | color: red; 7 | } 8 | 9 | :local(.smile) :local(.smile) :local(.smile) { 10 | color: red; 11 | } 12 | 13 | :local(.smile_with_A) { 14 | color: red; 15 | } 16 | 17 | .\1F600 :local(.smile) { 18 | color: red; 19 | } 20 | 21 | :local(.smile) .\1F600 { 22 | color: red; 23 | } 24 | 25 | .\1F600 :local(.smile) .\1F600 { 26 | color: red; 27 | } 28 | 29 | .\1F600 :local(.smile_with_A) .\1F600 { 30 | color: red; 31 | } 32 | 33 | #\1F600 :local(#smile) #\1F600 { 34 | color: red; 35 | } 36 | 37 | #\1F600 :local(#smile_with_A) #\1F600 { 38 | color: red; 39 | } 40 | 41 | .a :local(.smile) b { 42 | color: red; 43 | } 44 | 45 | :local(.smile) > :local(.smile) > :local(.smile) { 46 | color: red; 47 | } 48 | 49 | .\1F600 :local(.smile) { 50 | color: red; 51 | } 52 | 53 | .\1F600:local(.smile) { 54 | color: red; 55 | } 56 | 57 | .\1F600 :local(.smile) { 58 | color: red; 59 | } 60 | 61 | :local(.smile) .a { 62 | color: red; 63 | } 64 | 65 | :local(.smile).a { 66 | color: red; 67 | } 68 | 69 | .a :local(.smile) { 70 | color: red; 71 | } 72 | 73 | .a:local(.smile) { 74 | color: red; 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-modules-scope", 3 | "version": "3.2.1", 4 | "description": "A CSS Modules transform to extract export statements from local-scope classes", 5 | "main": "src/index.js", 6 | "engines": { 7 | "node": "^10 || ^12 || >= 14" 8 | }, 9 | "scripts": { 10 | "prettier": "prettier -l --ignore-path .gitignore . \"!test/test-cases\"", 11 | "eslint": "eslint --ignore-path .gitignore .", 12 | "lint": "yarn eslint && yarn prettier", 13 | "test:only": "jest", 14 | "test:watch": "jest --watch", 15 | "test:coverage": "jest --coverage --collectCoverageFrom=\"src/**/*\"", 16 | "pretest": "yarn lint", 17 | "test": "yarn test:coverage", 18 | "prepublishOnly": "yarn test" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/css-modules/postcss-modules-scope.git" 23 | }, 24 | "keywords": [ 25 | "css-modules", 26 | "postcss", 27 | "plugin" 28 | ], 29 | "files": [ 30 | "src" 31 | ], 32 | "author": "Glen Maddern", 33 | "license": "ISC", 34 | "bugs": { 35 | "url": "https://github.com/css-modules/postcss-modules-scope/issues" 36 | }, 37 | "homepage": "https://github.com/css-modules/postcss-modules-scope", 38 | "dependencies": { 39 | "postcss-selector-parser": "^7.0.0" 40 | }, 41 | "devDependencies": { 42 | "coveralls": "^3.1.0", 43 | "eslint": "^7.9.0", 44 | "eslint-config-prettier": "^6.12.0", 45 | "husky": "^4.3.0", 46 | "jest": "^26.4.2", 47 | "lint-staged": "^10.4.0", 48 | "postcss": "^8.3.0", 49 | "prettier": "^2.1.2" 50 | }, 51 | "peerDependencies": { 52 | "postcss": "^8.1.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/cases.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var fs = require("fs"); 4 | var path = require("path"); 5 | var postcss = require("postcss"); 6 | var processor = require("../src"); 7 | 8 | function generateInvalidCSS(css) { 9 | css.walkDecls(function (decl) { 10 | decl.value = decl.value.replace(/_colon_/g, ":"); // because using a : in the tests would make it invalid CSS. 11 | }); 12 | } 13 | 14 | function normalize(str) { 15 | return str.replace(/\r\n?/g, "\n").replace(/\n$/, ""); 16 | } 17 | 18 | var generateScopedName = processor.generateScopedName; 19 | 20 | describe("test-cases", function () { 21 | var testDir = path.join(__dirname, "test-cases"); 22 | fs.readdirSync(testDir).forEach(function (testCase) { 23 | if (fs.existsSync(path.join(testDir, testCase, "source.css"))) { 24 | it("should " + testCase.replace(/-/g, " "), () => { 25 | var input = normalize( 26 | fs.readFileSync(path.join(testDir, testCase, "source.css"), "utf-8") 27 | ); 28 | 29 | var expected, expectedError; 30 | 31 | if (fs.existsSync(path.join(testDir, testCase, "expected.error.txt"))) { 32 | expectedError = normalize( 33 | fs.readFileSync( 34 | path.join(testDir, testCase, "expected.error.txt"), 35 | "utf-8" 36 | ) 37 | ).split("\n")[0]; 38 | } else { 39 | expected = normalize( 40 | fs.readFileSync( 41 | path.join(testDir, testCase, "expected.css"), 42 | "utf-8" 43 | ) 44 | ); 45 | } 46 | 47 | var config = { from: "/input" }; 48 | var options = { 49 | generateScopedName: function (exportedName, inputPath) { 50 | var normalizedPath = inputPath.replace(/^[A-Z]:/, ""); 51 | return generateScopedName(exportedName, normalizedPath); 52 | }, 53 | }; 54 | 55 | if (fs.existsSync(path.join(testDir, testCase, "config.json"))) { 56 | config = JSON.parse( 57 | fs.readFileSync( 58 | path.join(testDir, testCase, "config.json"), 59 | "utf-8" 60 | ) 61 | ); 62 | } 63 | 64 | if (fs.existsSync(path.join(testDir, testCase, "options.js"))) { 65 | options = require(path.join(testDir, testCase, "options.js")); 66 | } 67 | 68 | var pipeline = postcss([generateInvalidCSS, processor(options)]); 69 | 70 | if (expectedError) { 71 | expect(() => { 72 | pipeline.process(input, config).css; 73 | }).toThrow(new RegExp(expectedError)); 74 | } else { 75 | expect(expected).toBe(pipeline.process(input, config).css); 76 | } 77 | }); 78 | } 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSS Modules: Scope Locals & Extend 2 | 3 | [![Build Status](https://travis-ci.org/css-modules/postcss-modules-scope.svg?branch=master)](https://travis-ci.org/css-modules/postcss-modules-scope) 4 | 5 | Transforms: 6 | 7 | ```css 8 | :local(.continueButton) { 9 | color: green; 10 | } 11 | ``` 12 | 13 | into: 14 | 15 | ```css 16 | :export { 17 | continueButton: __buttons_continueButton_djd347adcxz9; 18 | } 19 | .__buttons_continueButton_djd347adcxz9 { 20 | color: green; 21 | } 22 | ``` 23 | 24 | so it doesn't pollute CSS global scope and can be simply used in JS like so: 25 | 26 | ```js 27 | import styles from "./buttons.css"; 28 | elem.innerHTML = ``; 29 | ``` 30 | 31 | ## Composition 32 | 33 | Since we're exporting class names, there's no reason to export only one. This can give us some really useful reuse of styles: 34 | 35 | ```css 36 | .globalButtonStyle { 37 | background: white; 38 | border: 1px solid; 39 | border-radius: 0.25rem; 40 | } 41 | .globalButtonStyle:hover { 42 | box-shadow: 0 0 4px -2px; 43 | } 44 | :local(.continueButton) { 45 | compose-with: globalButtonStyle; 46 | color: green; 47 | } 48 | ``` 49 | 50 | becomes: 51 | 52 | ``` 53 | .globalButtonStyle { 54 | background: white; 55 | border: 1px solid; 56 | border-radius: 0.25rem; 57 | } 58 | .globalButtonStyle:hover { 59 | box-shadow: 0 0 4px -2px; 60 | } 61 | :local(.continueButton) { 62 | compose-with: globalButtonStyle; 63 | color: green; 64 | } 65 | ``` 66 | 67 | **Note:** you can also use `composes` as a shorthand for `compose-with` 68 | 69 | ## Local-by-default & reuse across files 70 | 71 | You're looking for [CSS Modules](https://github.com/css-modules/css-modules). It uses this plugin as well as a few others, and it's amazing. 72 | 73 | ## Building 74 | 75 | ``` 76 | npm install 77 | npm test 78 | ``` 79 | 80 | - Status: [![Build Status](https://travis-ci.org/css-modules/postcss-modules-scope.svg?branch=master)](https://travis-ci.org/css-modules/postcss-modules-scope) 81 | - Lines: [![Coverage Status](https://coveralls.io/repos/css-modules/postcss-modules-scope/badge.svg?branch=master)](https://coveralls.io/r/css-modules/postcss-modules-scope?branch=master) 82 | - Statements: [![codecov.io](http://codecov.io/github/css-modules/postcss-modules-scope/coverage.svg?branch=master)](http://codecov.io/github/css-modules/postcss-modules-scope?branch=master) 83 | 84 | ## Development 85 | 86 | - `npm test:watch` will watch `src` and `test` for changes and run the tests 87 | 88 | ## License 89 | 90 | ISC 91 | 92 | ## With thanks 93 | 94 | - Mark Dalgleish 95 | - Tobias Koppers 96 | - Guy Bedford 97 | 98 | --- 99 | 100 | Glen Maddern, 2015. 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## [3.2.1](https://github.com/postcss-modules-local-by-default/compare/v3.2.0...v3.2.1) 7 | 8 | ### Chore 9 | 10 | - update `postcss-selector-parser` 11 | 12 | ## [3.2.0](https://github.com/postcss-modules-local-by-default/compare/v3.1.2...v3.2.0) - 2024-04-03 13 | 14 | ### Features 15 | 16 | - supports multiple composes, i.e. `.class { composes: a b, global(c), d e from "./path/file.css" }` 17 | 18 | ## [3.1.2](https://github.com/postcss-modules-local-by-default/compare/v3.1.1...v3.1.2) - 2024-04-03 19 | 20 | ### Fixes 21 | 22 | - export a root and limit from the `@scope` at-rule 23 | 24 | ## [3.1.1](https://github.com/postcss-modules-local-by-default/compare/v3.1.0...v3.1.1) - 2024-01-18 25 | 26 | ### Fixes 27 | 28 | - handle `@scope` at-rule 29 | - fix CSS nesting logic 30 | 31 | ## [3.1.0](https://github.com/postcss-modules-local-by-default/compare/v3.0.0...v3.1.0) - 2023-12-21 32 | 33 | ### Fixes 34 | 35 | - scoped class attribute 36 | 37 | ### Features 38 | 39 | - pass a node to the `generateExportEntry` option 40 | 41 | ## [3.0.0](https://github.com/postcss-modules-local-by-default/compare/v3.0.0-rc.2...v3.0.0) - 2020-10-13 42 | 43 | ### Fixes 44 | 45 | - compatibility with plugins other plugins 46 | - handle animation short name 47 | - perf 48 | 49 | ## [3.0.0-rc.2](https://github.com/postcss-modules-local-by-default/compare/v3.0.0-rc.1...v3.0.0-rc.2) - 2020-10-11 50 | 51 | ### BREAKING CHANGE 52 | 53 | - minimum supported `postcss` version is `^8.1.0` 54 | 55 | ### Fixes 56 | 57 | - minimum supported `Node.js` version is `^10 || ^12 || >= 14` 58 | - compatibility with PostCSS 8 59 | 60 | ## [3.0.0-rc.1](https://github.com/postcss-modules-local-by-default/compare/v3.0.0-rc.0...v3.0.0-rc.1) - 2020-09-22 61 | 62 | ### BREAKING CHANGE 63 | 64 | - do not handle invalid syntax 65 | 66 | ## [3.0.0-rc.0](https://github.com/postcss-modules-local-by-default/compare/v2.2.0...v3.0.0-rc.0) - 2020-09-21 67 | 68 | ### BREAKING CHANGE 69 | 70 | - minimum supported `Node.js` version is `>= 10.13.0 || >= 12.13.0 || >= 14` 71 | - minimum supported `postcss` version is `^8.0.3` 72 | - `postcss` was moved to `peerDependencies`, you need to install `postcss` in your project before use the plugin 73 | 74 | ## 2.2.0 - 2020-03-19 75 | 76 | - added the `exportGlobals` option to export global classes and ids 77 | 78 | ## 2.1.1 - 2019-03-05 79 | 80 | ### Fixed 81 | 82 | - add additional space after the escape sequence (#17) 83 | 84 | ## [2.1.0] - 2019-03-05 85 | 86 | ### Fixed 87 | 88 | - handles properly selector with escaping characters (like: `.\31 a2b3c { color: red }`) 89 | 90 | ### Feature 91 | 92 | - `generateExportEntry` option (allow to setup key and value for `:export {}` rule) 93 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const selectorParser = require("postcss-selector-parser"); 4 | 5 | const hasOwnProperty = Object.prototype.hasOwnProperty; 6 | 7 | function isNestedRule(rule) { 8 | if (!rule.parent || rule.parent.type === "root") { 9 | return false; 10 | } 11 | 12 | if (rule.parent.type === "rule") { 13 | return true; 14 | } 15 | 16 | return isNestedRule(rule.parent); 17 | } 18 | 19 | function getSingleLocalNamesForComposes(root, rule) { 20 | if (isNestedRule(rule)) { 21 | throw new Error(`composition is not allowed in nested rule \n\n${rule}`); 22 | } 23 | 24 | return root.nodes.map((node) => { 25 | if (node.type !== "selector" || node.nodes.length !== 1) { 26 | throw new Error( 27 | `composition is only allowed when selector is single :local class name not in "${root}"` 28 | ); 29 | } 30 | 31 | node = node.nodes[0]; 32 | 33 | if ( 34 | node.type !== "pseudo" || 35 | node.value !== ":local" || 36 | node.nodes.length !== 1 37 | ) { 38 | throw new Error( 39 | 'composition is only allowed when selector is single :local class name not in "' + 40 | root + 41 | '", "' + 42 | node + 43 | '" is weird' 44 | ); 45 | } 46 | 47 | node = node.first; 48 | 49 | if (node.type !== "selector" || node.length !== 1) { 50 | throw new Error( 51 | 'composition is only allowed when selector is single :local class name not in "' + 52 | root + 53 | '", "' + 54 | node + 55 | '" is weird' 56 | ); 57 | } 58 | 59 | node = node.first; 60 | 61 | if (node.type !== "class") { 62 | // 'id' is not possible, because you can't compose ids 63 | throw new Error( 64 | 'composition is only allowed when selector is single :local class name not in "' + 65 | root + 66 | '", "' + 67 | node + 68 | '" is weird' 69 | ); 70 | } 71 | 72 | return node.value; 73 | }); 74 | } 75 | 76 | const whitespace = "[\\x20\\t\\r\\n\\f]"; 77 | const unescapeRegExp = new RegExp( 78 | "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", 79 | "ig" 80 | ); 81 | 82 | function unescape(str) { 83 | return str.replace(unescapeRegExp, (_, escaped, escapedWhitespace) => { 84 | const high = "0x" + escaped - 0x10000; 85 | 86 | // NaN means non-codepoint 87 | // Workaround erroneous numeric interpretation of +"0x" 88 | return high !== high || escapedWhitespace 89 | ? escaped 90 | : high < 0 91 | ? // BMP codepoint 92 | String.fromCharCode(high + 0x10000) 93 | : // Supplemental Plane codepoint (surrogate pair) 94 | String.fromCharCode((high >> 10) | 0xd800, (high & 0x3ff) | 0xdc00); 95 | }); 96 | } 97 | 98 | const plugin = (options = {}) => { 99 | const generateScopedName = 100 | (options && options.generateScopedName) || plugin.generateScopedName; 101 | const generateExportEntry = 102 | (options && options.generateExportEntry) || plugin.generateExportEntry; 103 | const exportGlobals = options && options.exportGlobals; 104 | 105 | return { 106 | postcssPlugin: "postcss-modules-scope", 107 | Once(root, { rule }) { 108 | const exports = Object.create(null); 109 | 110 | function exportScopedName(name, rawName, node) { 111 | const scopedName = generateScopedName( 112 | rawName ? rawName : name, 113 | root.source.input.from, 114 | root.source.input.css, 115 | node 116 | ); 117 | const exportEntry = generateExportEntry( 118 | rawName ? rawName : name, 119 | scopedName, 120 | root.source.input.from, 121 | root.source.input.css, 122 | node 123 | ); 124 | const { key, value } = exportEntry; 125 | 126 | exports[key] = exports[key] || []; 127 | 128 | if (exports[key].indexOf(value) < 0) { 129 | exports[key].push(value); 130 | } 131 | 132 | return scopedName; 133 | } 134 | 135 | function localizeNode(node) { 136 | switch (node.type) { 137 | case "selector": 138 | node.nodes = node.map((item) => localizeNode(item)); 139 | return node; 140 | case "class": 141 | return selectorParser.className({ 142 | value: exportScopedName( 143 | node.value, 144 | node.raws && node.raws.value ? node.raws.value : null, 145 | node 146 | ), 147 | }); 148 | case "id": { 149 | return selectorParser.id({ 150 | value: exportScopedName( 151 | node.value, 152 | node.raws && node.raws.value ? node.raws.value : null, 153 | node 154 | ), 155 | }); 156 | } 157 | case "attribute": { 158 | if (node.attribute === "class" && node.operator === "=") { 159 | return selectorParser.attribute({ 160 | attribute: node.attribute, 161 | operator: node.operator, 162 | quoteMark: "'", 163 | value: exportScopedName(node.value, null, null), 164 | }); 165 | } 166 | } 167 | } 168 | 169 | throw new Error( 170 | `${node.type} ("${node}") is not allowed in a :local block` 171 | ); 172 | } 173 | 174 | function traverseNode(node) { 175 | switch (node.type) { 176 | case "pseudo": 177 | if (node.value === ":local") { 178 | if (node.nodes.length !== 1) { 179 | throw new Error('Unexpected comma (",") in :local block'); 180 | } 181 | 182 | const selector = localizeNode(node.first); 183 | // move the spaces that were around the pseudo selector to the first 184 | // non-container node 185 | selector.first.spaces = node.spaces; 186 | 187 | const nextNode = node.next(); 188 | 189 | if ( 190 | nextNode && 191 | nextNode.type === "combinator" && 192 | nextNode.value === " " && 193 | /\\[A-F0-9]{1,6}$/.test(selector.last.value) 194 | ) { 195 | selector.last.spaces.after = " "; 196 | } 197 | 198 | node.replaceWith(selector); 199 | 200 | return; 201 | } 202 | /* falls through */ 203 | case "root": 204 | case "selector": { 205 | node.each((item) => traverseNode(item)); 206 | break; 207 | } 208 | case "id": 209 | case "class": 210 | if (exportGlobals) { 211 | exports[node.value] = [node.value]; 212 | } 213 | break; 214 | } 215 | return node; 216 | } 217 | 218 | // Find any :import and remember imported names 219 | const importedNames = {}; 220 | 221 | root.walkRules(/^:import\(.+\)$/, (rule) => { 222 | rule.walkDecls((decl) => { 223 | importedNames[decl.prop] = true; 224 | }); 225 | }); 226 | 227 | // Find any :local selectors 228 | root.walkRules((rule) => { 229 | let parsedSelector = selectorParser().astSync(rule); 230 | 231 | rule.selector = traverseNode(parsedSelector.clone()).toString(); 232 | 233 | rule.walkDecls(/^(composes|compose-with)$/i, (decl) => { 234 | const localNames = getSingleLocalNamesForComposes( 235 | parsedSelector, 236 | decl.parent 237 | ); 238 | const multiple = decl.value.split(","); 239 | 240 | multiple.forEach((value) => { 241 | const classes = value.trim().split(/\s+/); 242 | 243 | classes.forEach((className) => { 244 | const global = /^global\(([^)]+)\)$/.exec(className); 245 | 246 | if (global) { 247 | localNames.forEach((exportedName) => { 248 | exports[exportedName].push(global[1]); 249 | }); 250 | } else if (hasOwnProperty.call(importedNames, className)) { 251 | localNames.forEach((exportedName) => { 252 | exports[exportedName].push(className); 253 | }); 254 | } else if (hasOwnProperty.call(exports, className)) { 255 | localNames.forEach((exportedName) => { 256 | exports[className].forEach((item) => { 257 | exports[exportedName].push(item); 258 | }); 259 | }); 260 | } else { 261 | throw decl.error( 262 | `referenced class name "${className}" in ${decl.prop} not found` 263 | ); 264 | } 265 | }); 266 | }); 267 | 268 | decl.remove(); 269 | }); 270 | 271 | // Find any :local values 272 | rule.walkDecls((decl) => { 273 | if (!/:local\s*\((.+?)\)/.test(decl.value)) { 274 | return; 275 | } 276 | 277 | let tokens = decl.value.split(/(,|'[^']*'|"[^"]*")/); 278 | 279 | tokens = tokens.map((token, idx) => { 280 | if (idx === 0 || tokens[idx - 1] === ",") { 281 | let result = token; 282 | 283 | const localMatch = /:local\s*\((.+?)\)/.exec(token); 284 | 285 | if (localMatch) { 286 | const input = localMatch.input; 287 | const matchPattern = localMatch[0]; 288 | const matchVal = localMatch[1]; 289 | const newVal = exportScopedName(matchVal); 290 | 291 | result = input.replace(matchPattern, newVal); 292 | } else { 293 | return token; 294 | } 295 | 296 | return result; 297 | } else { 298 | return token; 299 | } 300 | }); 301 | 302 | decl.value = tokens.join(""); 303 | }); 304 | }); 305 | 306 | // Find any :local keyframes 307 | root.walkAtRules(/keyframes$/i, (atRule) => { 308 | const localMatch = /^\s*:local\s*\((.+?)\)\s*$/.exec(atRule.params); 309 | 310 | if (!localMatch) { 311 | return; 312 | } 313 | 314 | atRule.params = exportScopedName(localMatch[1]); 315 | }); 316 | 317 | root.walkAtRules(/scope$/i, (atRule) => { 318 | if (atRule.params) { 319 | atRule.params = atRule.params 320 | .split("to") 321 | .map((item) => { 322 | const selector = item.trim().slice(1, -1).trim(); 323 | 324 | const localMatch = /^\s*:local\s*\((.+?)\)\s*$/.exec(selector); 325 | 326 | if (!localMatch) { 327 | return `(${selector})`; 328 | } 329 | 330 | let parsedSelector = selectorParser().astSync(selector); 331 | 332 | return `(${traverseNode(parsedSelector).toString()})`; 333 | }) 334 | .join(" to "); 335 | } 336 | }); 337 | 338 | // If we found any :locals, insert an :export rule 339 | const exportedNames = Object.keys(exports); 340 | 341 | if (exportedNames.length > 0) { 342 | const exportRule = rule({ selector: ":export" }); 343 | 344 | exportedNames.forEach((exportedName) => 345 | exportRule.append({ 346 | prop: exportedName, 347 | value: exports[exportedName].join(" "), 348 | raws: { before: "\n " }, 349 | }) 350 | ); 351 | 352 | root.append(exportRule); 353 | } 354 | }, 355 | }; 356 | }; 357 | 358 | plugin.postcss = true; 359 | 360 | plugin.generateScopedName = function (name, path) { 361 | const sanitisedPath = path 362 | .replace(/\.[^./\\]+$/, "") 363 | .replace(/[\W_]+/g, "_") 364 | .replace(/^_|_$/g, ""); 365 | 366 | return `_${sanitisedPath}__${name}`.trim(); 367 | }; 368 | 369 | plugin.generateExportEntry = function (name, scopedName) { 370 | return { 371 | key: unescape(name), 372 | value: unescape(scopedName), 373 | }; 374 | }; 375 | 376 | module.exports = plugin; 377 | --------------------------------------------------------------------------------