├── .gitignore ├── classic ├── consts.js ├── write-files.js ├── test │ └── index.js ├── index.js ├── entity-to-file-path.js └── build-structure.js ├── bin ├── create-by-css └── bemify ├── lib └── entity-by-selector.js ├── test ├── index.js ├── test.css └── expected.js ├── package.json ├── .github └── workflows │ └── node.js.yml ├── index.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /classic/consts.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | elemSeparator: process.env.ELEM_SEPARATOR || '__', 3 | modSeparator: process.env.ELEM_MOD_SEPARATOR || '_' 4 | }; 5 | -------------------------------------------------------------------------------- /bin/create-by-css: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const [pathToFile, level, tech] = process.argv.slice(2); 5 | const css = fs.readFileSync(pathToFile, 'utf8'); 6 | 7 | require('..')(css, { level: level || 'blocks', tech: tech || 'css' }); 8 | -------------------------------------------------------------------------------- /bin/bemify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const [pathToFile, level, tech] = process.argv.slice(2); 5 | const css = fs.readFileSync(pathToFile, 'utf8'); 6 | 7 | require('../classic')(css, { level: level || 'blocks', tech: tech || 'css', buildImports: true }); 8 | -------------------------------------------------------------------------------- /classic/write-files.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | module.exports = function writeFiles(fsStructure) { 5 | for (const [filePath, content] of Object.entries(fsStructure)) { 6 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); 7 | fs.writeFileSync(filePath, content); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /classic/test/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const assert = require('assert'); 4 | const buildStructure = require('../build-structure'); 5 | 6 | const css = fs.readFileSync(path.join(__dirname, '../../test/test.css'), 'utf-8'); 7 | const expected = require('../../test/expected'); 8 | 9 | assert.deepEqual(buildStructure(css, 'css'), expected); 10 | -------------------------------------------------------------------------------- /lib/entity-by-selector.js: -------------------------------------------------------------------------------- 1 | const postcssSelectorParser = require('postcss-selector-parser'); 2 | 3 | module.exports = function getEntityBySelector(selectors) { 4 | let result = ''; 5 | 6 | postcssSelectorParser(selectors => { 7 | selectors.walkClasses(node => { 8 | if (!result) { 9 | result = node.value; 10 | } 11 | }); 12 | }).processSync(selectors); 13 | 14 | return result; 15 | }; 16 | -------------------------------------------------------------------------------- /classic/index.js: -------------------------------------------------------------------------------- 1 | const buildStructure = require('./build-structure'); 2 | const writeFiles = require('./write-files'); 3 | 4 | module.exports = function(css, { level, tech, buildImports }) { 5 | const structureWithLevel = Object.entries(buildStructure(css, tech)) 6 | .reduce((acc, [filePath, content]) => { 7 | acc[level + '/' + filePath] = content; 8 | 9 | return acc; 10 | }, {}); 11 | 12 | if (buildImports) { 13 | const indexCSS = Object.keys(structureWithLevel) 14 | .map(filePath => `@import url(${filePath});`) 15 | .join('\n'); 16 | 17 | console.log(indexCSS); 18 | } 19 | 20 | writeFiles(structureWithLevel); 21 | }; 22 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const assert = require('assert'); 3 | const expected = require('./expected'); 4 | 5 | const pathToBlocks = 'test/blocks'; 6 | fs.rmSync(pathToBlocks, { force: true, recursive: true }); 7 | 8 | const css = fs.readFileSync('test/test.css', 'utf8'); 9 | 10 | require('..')(css, { level: pathToBlocks, tech: 'css' }).then(() => { 11 | // TODO: fixme 12 | setTimeout(() => { 13 | for ([filePath, reference] of Object.entries(expected)) { 14 | const actual = fs.readFileSync(`${pathToBlocks}/${filePath}`, 'utf-8'); 15 | assert.strictEqual(actual, reference); 16 | } 17 | 18 | fs.rmSync(pathToBlocks, { force: true, recursive: true }); 19 | }, 500); 20 | }); 21 | -------------------------------------------------------------------------------- /classic/entity-to-file-path.js: -------------------------------------------------------------------------------- 1 | const { elemSeparator, modSeparator } = require('./consts'); 2 | 3 | module.exports = function getFilePathByEntity(entity, tech) { 4 | const [block, elem] = entity.split(elemSeparator); 5 | const [blockName, blockModName] = block.split(modSeparator); 6 | 7 | if (elem) { 8 | const [elemName, elemModName] = elem.split(modSeparator); 9 | 10 | return [ 11 | `${blockName}`, 12 | `${elemSeparator}${elemName}`, 13 | elemModName && `${modSeparator}${elemModName}`, 14 | `${entity}.${tech}` 15 | ].filter(Boolean).join('/'); 16 | } 17 | 18 | return [ 19 | `${blockName}`, 20 | blockModName && `${modSeparator}${blockModName}`, 21 | `${entity}.${tech}` 22 | ].filter(Boolean).join('/'); 23 | } 24 | -------------------------------------------------------------------------------- /test/test.css: -------------------------------------------------------------------------------- 1 | .b1 { color: red; color: green; } 2 | .b1 { animation-name: test; } 3 | .b1.b2 { color: yellowgreen; } 4 | 5 | @media (max-width: 1250px) { 6 | .b1 { width: 50%; } 7 | .b2 { width: 75%; } 8 | } 9 | 10 | .b1, .b2 { color: pink; } 11 | 12 | .b1:hover { color: brown; } 13 | 14 | .b1__e1 { color: yellow; } 15 | .b1__e1::after { content: ""; } 16 | 17 | .b1_m1 { color: honeydew; } 18 | 19 | .b1_m1_v1 { color: lightcoral; } 20 | 21 | .b1_m1_v2 .b1__e1 { color: hotpink; } 22 | 23 | .b2 { color: green; } 24 | 25 | .b2__e1_m1 { color: #eee; } 26 | 27 | /* 28 | :root { 29 | --my-var: brown; 30 | } 31 | 32 | .my-block { 33 | color: var(--my-var); 34 | } 35 | */ 36 | 37 | @keyframes test { 38 | from { 39 | transform: translateX(0%); 40 | } 41 | 42 | to { 43 | transform: translateX(100%); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-to-bem-file-structure", 3 | "version": "0.0.5", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node test && node classic/test" 8 | }, 9 | "keywords": [ 10 | "bem", 11 | "css", 12 | "files" 13 | ], 14 | "bin": { 15 | "css-to-bem-file-structure": "./bin/create-by-css", 16 | "bemify": "./bin/bemify" 17 | }, 18 | "author": "Vladimir Grinenko (https://github.com/tadatuta)", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/tadatuta/bem-tools-create-by-css/issues/new" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git://github.com/tadatuta/bem-tools-create-by-css.git" 26 | }, 27 | "dependencies": { 28 | "bem-tools-create": "^2.3.0", 29 | "postcss": "^8.4.21", 30 | "postcss-selector-parser": "^6.0.11" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x, 16.x, 18.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /test/expected.js: -------------------------------------------------------------------------------- 1 | const expected = { 2 | 'b1/b1.css': [ 3 | '.b1 { color: red; color: green; }', 4 | '.b1 { animation-name: test; }', 5 | '.b1.b2 { color: yellowgreen; }', 6 | '@media (max-width: 1250px) {', 7 | ' .b1 { width: 50%; }', 8 | '}', 9 | '.b1 { color: pink; }', 10 | '.b1:hover { color: brown; }', 11 | ].join('\n'), 12 | 'b2/b2.css': [ 13 | '@media (max-width: 1250px) {', 14 | ' .b2 { width: 75%; }', 15 | '}', 16 | '.b2 { color: pink; }', 17 | '.b2 { color: green; }', 18 | ].join('\n'), 19 | 'b1/__e1/b1__e1.css': [ 20 | '.b1__e1 { color: yellow; }', 21 | '.b1__e1::after { content: ""; }', 22 | ].join('\n'), 23 | 'b1/_m1/b1_m1.css': '.b1_m1 { color: honeydew; }', 24 | 'b1/_m1/b1_m1_v1.css': '.b1_m1_v1 { color: lightcoral; }', 25 | 'b1/_m1/b1_m1_v2.css': '.b1_m1_v2 .b1__e1 { color: hotpink; }', 26 | 'b2/__e1/_m1/b2__e1_m1.css': '.b2__e1_m1 { color: #eee; }' 27 | } 28 | 29 | module.exports = expected; 30 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss'); 2 | const create = require('bem-tools-create'); 3 | const getEntityBySelector = require('./lib/entity-by-selector'); 4 | 5 | module.exports = async function(css, { level, tech }) { 6 | const ast = postcss.parse(css); 7 | 8 | const entities = {}; 9 | 10 | ast.walkAtRules('keyframes', atRule => { 11 | console.warn(`ATTENTION: unhandled @keyframes ${atRule.params} rule found!`); 12 | }); 13 | 14 | ast.walkRules(rule => { 15 | const selector = rule.selector; 16 | if (!selector.startsWith('.')) { 17 | return; 18 | }; 19 | 20 | const parent = rule.parent; 21 | selector.split(',').forEach(subSelector => { 22 | const entity = getEntityBySelector(subSelector); 23 | 24 | const subRule = rule.clone(); 25 | subRule.selector = subSelector.trim(); 26 | 27 | let css = ''; 28 | if (parent.type === 'atrule') { 29 | css = [ 30 | `@${parent.name} ${parent.params} {`, 31 | subRule.toString() 32 | .split('\n') 33 | .map(line => ` ${line}`) 34 | .join('\n'), 35 | '}', 36 | ].join('\n'); 37 | } else { 38 | css = subRule.toString(); 39 | } 40 | 41 | if (!entities[entity]) { 42 | entities[entity] = [css]; 43 | } else { 44 | entities[entity].push(css); 45 | } 46 | }); 47 | }); 48 | 49 | for (const [entity, content] of Object.entries(entities)) { 50 | await create([entity], [level], tech, { fileContent: content.join('\n') }); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # css-to-bem-file-structure 2 | 3 | Generate [BEM file structure](https://en.bem.info/methodology/filestructure/) by CSS file. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | npm i css-to-bem-file-structure --save-dev 9 | ``` 10 | 11 | ## Usage 12 | 13 | To generate [nested structure](https://en.bem.info/methodology/filestructure/#nested) use 14 | ```sh 15 | ./node_modules/.bin/bemify path-to-styles.css 16 | ``` 17 | 18 | In this case you may customize separators with environment variables `ELEM_SEPARATOR` and `ELEM_MOD_SEPARATOR`. 19 | 20 | ## Advanced usage 21 | 22 | To customize [file structure organization](https://en.bem.info/methodology/filestructure/) use `css-to-bem-file-structure` binary. It supports the same [bem-config](https://github.com/bem/bem-sdk/tree/master/packages/config#config) file as [bem-tools-create](https://www.npmjs.com/package/bem-tools-create#configuration) package. 23 | 24 | NOTE: such configuration was never tested and considered deprecated. List of imports won't be generated in this case. 25 | 26 | ```sh 27 | ./node_modules/.bin/css-to-bem-file-structure path-to-styles.css 28 | ``` 29 | 30 | ```sh 31 | ./node_modules/.bin/css-to-bem-file-structure path-to-styles.css blocks css 32 | ``` 33 | 34 | ## How it works 35 | 36 | For file `test.css` with 37 | 38 | ```css 39 | .b1 { color: red; } 40 | 41 | .b1__e1 { color: yellow; } 42 | 43 | .b1_m1_v1 { color: lightcoral; } 44 | 45 | .b2 { color: green; } 46 | 47 | .b2__e1_m1 { color: #eee; } 48 | ``` 49 | 50 | following files will be generated: 51 | 52 | ``` 53 | blocks/ 54 | b1/ 55 | __e1/ 56 | b1__e1.css 57 | _m1/ 58 | b1_m1_v1.css 59 | b1.css 60 | 61 | b2/ 62 | __e1/ 63 | _m1/ 64 | b2__e1_m1.css 65 | b2.css 66 | ``` 67 | -------------------------------------------------------------------------------- /classic/build-structure.js: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss'); 2 | const getEntityBySelector = require('../lib/entity-by-selector'); 3 | const getFilePathByEntity = require('./entity-to-file-path'); 4 | 5 | module.exports = function buildStructure(css, tech) { 6 | const ast = postcss.parse(css); 7 | 8 | const entities = {}; 9 | 10 | ast.walkAtRules('keyframes', atRule => { 11 | console.warn(`ATTENTION: unhandled @keyframes ${atRule.params} rule found!`); 12 | }); 13 | 14 | ast.walkRules(rule => { 15 | const selector = rule.selector; 16 | if (!selector.startsWith('.')) { 17 | return; 18 | }; 19 | 20 | const parent = rule.parent; 21 | selector.split(',').forEach(subSelector => { 22 | const entity = getEntityBySelector(subSelector); 23 | 24 | const subRule = rule.clone(); 25 | subRule.selector = subSelector.trim(); 26 | 27 | let css = ''; 28 | if (parent.type === 'atrule') { 29 | css = [ 30 | `@${parent.name} ${parent.params} {`, 31 | subRule.toString() 32 | .split('\n') 33 | .map(line => ` ${line}`) 34 | .join('\n'), 35 | '}', 36 | ].join('\n'); 37 | } else { 38 | css = subRule.toString(); 39 | } 40 | 41 | if (!entities[entity]) { 42 | entities[entity] = [css]; 43 | } else { 44 | entities[entity].push(css); 45 | } 46 | }); 47 | }); 48 | 49 | return Object.entries(entities).reduce((acc, [entity, content]) => { 50 | const filePath = getFilePathByEntity(entity, tech); 51 | acc[filePath] = content.join('\n'); 52 | 53 | return acc; 54 | }, {}); 55 | }; 56 | --------------------------------------------------------------------------------