diff --git a/.gitignore b/.gitignore index 7079b99..662dc5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -node_modules +.DS_Store coverage lib +node_modules +yarn.lock diff --git a/package.json b/package.json index 267951b..5124c55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postcss-modules-extract-imports", - "version": "1.1.0", + "version": "1.2.0", "description": "A CSS Modules transform to extract local aliases for inline imports", "main": "lib/index.js", "scripts": { diff --git a/src/index.js b/src/index.js index f4f3c87..9926d71 100644 --- a/src/index.js +++ b/src/index.js @@ -1,69 +1,169 @@ import postcss from 'postcss'; +import topologicalSort from './topologicalSort'; -const declWhitelist = ['composes'], - declFilter = new RegExp( `^(${declWhitelist.join( '|' )})$` ), - matchImports = /^(.+?)\s+from\s+(?:"([^"]+)"|'([^']+)'|(global))$/, - icssImport = /^:import\((?:"([^"]+)"|'([^']+)')\)/; +const declWhitelist = ['composes']; +const declFilter = new RegExp( `^(${declWhitelist.join( '|' )})$` ); +const matchImports = /^(.+?)\s+from\s+(?:"([^"]+)"|'([^']+)'|(global))$/; +const icssImport = /^:import\((?:"([^"]+)"|'([^']+)')\)/; -const processor = postcss.plugin( 'modules-extract-imports', function ( options ) { - return ( css ) => { - let imports = {}, - importIndex = 0, - createImportedName = options && options.createImportedName || (( importName/*, path*/ ) => `i__imported_${importName.replace( /\W/g, '_' )}_${importIndex++}`); +const VISITED_MARKER = 1; + +function createParentName(rule, root) { + return `__${root.index(rule.parent)}_${rule.selector}`; +} + +function serializeImports(imports) { + return imports.map(importPath => '`' + importPath + '`').join(', '); +} + +/** + * :import('G') {} + * + * Rule + * composes: ... from 'A' + * composes: ... from 'B' + + * Rule + * composes: ... from 'A' + * composes: ... from 'A' + * composes: ... from 'C' + * + * Results in: + * + * graph: { + * G: [], + * A: [], + * B: ['A'], + * C: ['A'], + * } + */ +function addImportToGraph(importId, parentId, graph, visited) { + const siblingsId = parentId + '_' + 'siblings'; + const visitedId = parentId + '_' + importId; + + if (visited[visitedId] !== VISITED_MARKER) { + if (!Array.isArray(visited[siblingsId])) visited[siblingsId] = []; + + const siblings = visited[siblingsId]; + + if (Array.isArray(graph[importId])) + graph[importId] = graph[importId].concat(siblings); + else + graph[importId] = siblings.slice(); + + visited[visitedId] = VISITED_MARKER; + siblings.push(importId); + } +} + +const processor = postcss.plugin('modules-extract-imports', function (options = {}) { + const failOnWrongOrder = options.failOnWrongOrder; + + return css => { + const graph = {}; + const visited = {}; + + const existingImports = {}; + const importDecls = {}; + const imports = {}; + + let importIndex = 0; + + const createImportedName = typeof options.createImportedName !== 'function' + ? (importName/*, path*/) => `i__imported_${importName.replace(/\W/g, '_')}_${importIndex++}` + : options.createImportedName; + + // Check the existing imports order and save refs + css.walkRules(rule => { + const matches = icssImport.exec(rule.selector); + + if (matches) { + const [/*match*/, doubleQuotePath, singleQuotePath] = matches; + const importPath = doubleQuotePath || singleQuotePath; + + addImportToGraph(importPath, 'root', graph, visited); + + existingImports[importPath] = rule; + } + }); // Find any declaration that supports imports - css.walkDecls( declFilter, ( decl ) => { - let matches = decl.value.match( matchImports ); + css.walkDecls(declFilter, decl => { + let matches = decl.value.match(matchImports); let tmpSymbols; - if ( matches ) { + + if (matches) { let [/*match*/, symbols, doubleQuotePath, singleQuotePath, global] = matches; + if (global) { // Composing globals simply means changing these classes to wrap them in global(name) - tmpSymbols = symbols.split(/\s+/).map(s => `global(${s})`) + tmpSymbols = symbols.split(/\s+/).map(s => `global(${s})`); } else { - let path = doubleQuotePath || singleQuotePath; - imports[path] = imports[path] || {}; - tmpSymbols = symbols.split(/\s+/) - .map(s => { - if (!imports[path][s]) { - imports[path][s] = createImportedName(s, path); - } - return imports[path][s]; - }); + const importPath = doubleQuotePath || singleQuotePath; + const parentRule = createParentName(decl.parent, css); + + addImportToGraph(importPath, parentRule, graph, visited); + + importDecls[importPath] = decl; + imports[importPath] = imports[importPath] || {}; + + tmpSymbols = symbols.split(/\s+/).map(s => { + if (!imports[importPath][s]) { + imports[importPath][s] = createImportedName(s, importPath); + } + + return imports[importPath][s]; + }); } - decl.value = tmpSymbols.join( ' ' ); - } - } ); - // If we've found any imports, insert or append :import rules - let existingImports = {}; - css.walkRules(rule => { - let matches = icssImport.exec(rule.selector); - if (matches) { - let [/*match*/, doubleQuotePath, singleQuotePath] = matches; - existingImports[doubleQuotePath || singleQuotePath] = rule; + decl.value = tmpSymbols.join(' '); } }); - Object.keys( imports ).reverse().forEach( path => { + const importsOrder = topologicalSort(graph, failOnWrongOrder); + + if (importsOrder instanceof Error) { + const importPath = importsOrder.nodes.find(importPath => importDecls.hasOwnProperty(importPath)); + const decl = importDecls[importPath]; + + const errMsg = 'Failed to resolve order of composed modules ' + serializeImports(importsOrder.nodes) + '.'; + throw decl.error(errMsg, { + plugin: 'modules-extract-imports', + word: 'composes', + }); + } + + let lastImportRule; + importsOrder.forEach(path => { + const importedSymbols = imports[path]; let rule = existingImports[path]; - if (!rule) { - rule = postcss.rule( { + + if (!rule && importedSymbols) { + rule = postcss.rule({ selector: `:import("${path}")`, - raws: { after: "\n" } - } ); - css.prepend( rule ); + raws: {after: '\n'}, + }); + + if (lastImportRule) + css.insertAfter(lastImportRule, rule); + else + css.prepend(rule); } - Object.keys( imports[path] ).forEach( importedSymbol => { - rule.append(postcss.decl( { + + lastImportRule = rule; + + if (!importedSymbols) return; + + Object.keys(importedSymbols).forEach(importedSymbol => { + rule.append(postcss.decl({ value: importedSymbol, - prop: imports[path][importedSymbol], - raws: { before: "\n " } - } ) ); - } ); - } ); + prop: importedSymbols[importedSymbol], + raws: {before: '\n '}, + })); + }); + }); }; -} ); +}); export default processor; diff --git a/src/topologicalSort.js b/src/topologicalSort.js new file mode 100644 index 0000000..e8ace57 --- /dev/null +++ b/src/topologicalSort.js @@ -0,0 +1,50 @@ +const PERMANENT_MARKER = 2; +const TEMPORARY_MARKER = 1; + +function createError(node, graph) { + const er = new Error('Nondeterministic import\'s order'); + + const related = graph[node]; + const relatedNode = related.find(relatedNode => graph[relatedNode].indexOf(node) > -1); + + er.nodes = [node, relatedNode]; + + return er; +} + +function walkGraph(node, graph, state, result, strict) { + if (state[node] === PERMANENT_MARKER) return; + if (state[node] === TEMPORARY_MARKER) { + if (strict) return createError(node, graph); + return; + } + + state[node] = TEMPORARY_MARKER; + + const children = graph[node]; + const length = children.length; + + for (let i = 0; i < length; ++i) { + const er = walkGraph(children[i], graph, state, result, strict); + if (er instanceof Error) return er; + } + + state[node] = PERMANENT_MARKER; + + result.push(node); +} + +export default function topologicalSort(graph, strict) { + const result = []; + const state = {}; + + const nodes = Object.keys(graph); + const length = nodes.length; + + for (let i = 0; i < length; ++i) { + const er = walkGraph(nodes[i], graph, state, result, strict); + if (er instanceof Error) return er; + } + + return result; +} diff --git a/test/check-import-order.js b/test/check-import-order.js new file mode 100644 index 0000000..7b55b2d --- /dev/null +++ b/test/check-import-order.js @@ -0,0 +1,32 @@ +'use strict'; + +const assert = require('assert'); +const postcss = require('postcss'); +const processor = require('../'); + +describe('check-import-order', () => { + let pipeline; + + beforeEach(() => { + pipeline = postcss([ + processor({failOnWrongOrder: true}), + ]); + }); + + it('should throw an exception', () => { + const input = ` + .aa { + composes: b from './b.css'; + composes: c from './c.css'; + } + + .bb { + composes: c from './c.css'; + composes: b from './b.css'; + } + `; + + assert.throws(() => pipeline.process(input).css, + /Failed to resolve order of composed modules/); + }); +}); diff --git a/test/test-cases/resolve-composes-order/expected.css b/test/test-cases/resolve-composes-order/expected.css new file mode 100644 index 0000000..dfd08e4 --- /dev/null +++ b/test/test-cases/resolve-composes-order/expected.css @@ -0,0 +1,19 @@ +:import("./b.css") { + i__imported_b_1: b; +} + +:import("./c.css") { + i__imported_c_0: c; +} + +.a { + composes: i__imported_c_0; + color: #bebebe; +} + +.b { + /* `b` should be after `c` */ + composes: i__imported_b_1; + composes: i__imported_c_0; + color: #aaa; +} diff --git a/test/test-cases/resolve-composes-order/source.css b/test/test-cases/resolve-composes-order/source.css new file mode 100644 index 0000000..4c781b4 --- /dev/null +++ b/test/test-cases/resolve-composes-order/source.css @@ -0,0 +1,11 @@ +.a { + composes: c from "./c.css"; + color: #bebebe; +} + +.b { + /* `b` should be after `c` */ + composes: b from "./b.css"; + composes: c from "./c.css"; + color: #aaa; +} diff --git a/test/test-cases/resolve-duplicates/expected.css b/test/test-cases/resolve-duplicates/expected.css new file mode 100644 index 0000000..dc3e3d8 --- /dev/null +++ b/test/test-cases/resolve-duplicates/expected.css @@ -0,0 +1,20 @@ +:import("./aa.css") { + i__imported_a_0: a; +} + +:import("./bb.css") { + i__imported_b_1: b; +} + +:import("./cc.css") { + smthing: somevalue; + i__imported_c_2: c; +} + +.a { + composes: i__imported_a_0; + composes: i__imported_b_1; + composes: i__imported_c_2; + composes: i__imported_a_0; + composes: i__imported_c_2; +} diff --git a/test/test-cases/resolve-duplicates/source.css b/test/test-cases/resolve-duplicates/source.css new file mode 100644 index 0000000..83cf5fd --- /dev/null +++ b/test/test-cases/resolve-duplicates/source.css @@ -0,0 +1,11 @@ +:import("./cc.css") { + smthing: somevalue; +} + +.a { + composes: a from './aa.css'; + composes: b from './bb.css'; + composes: c from './cc.css'; + composes: a from './aa.css'; + composes: c from './cc.css'; +} diff --git a/test/test-cases/resolve-imports-order/expected.css b/test/test-cases/resolve-imports-order/expected.css new file mode 100644 index 0000000..4bb06c1 --- /dev/null +++ b/test/test-cases/resolve-imports-order/expected.css @@ -0,0 +1,44 @@ +:import("custom-path.css") { + /* empty to check the order */ +} + +:import("./cc.css") { + i__imported_cc_3: cc; + i__imported_dd_5: dd; +} + +:import("./bb.css") { + somevalue: localvalue; + i__imported_bb_1: bb; + i__imported_dd_6: dd; +} + +:import("./aa.css") { + i__imported_aa_0: aa; + i__imported_bb_2: bb; + i__imported_cc_4: cc; +} + +:import("./dd.css") { + i__imported_dd_7: dd; +} + +.a { + composes: i__imported_aa_0; +} + +.b { + composes: i__imported_bb_1; + composes: i__imported_bb_2; +} + +.c { + composes: i__imported_cc_3; + composes: i__imported_cc_4; +} + +.d { + composes: i__imported_dd_5; + composes: i__imported_dd_6; + composes: i__imported_dd_7; +} diff --git a/test/test-cases/resolve-imports-order/source.css b/test/test-cases/resolve-imports-order/source.css new file mode 100644 index 0000000..234a1c3 --- /dev/null +++ b/test/test-cases/resolve-imports-order/source.css @@ -0,0 +1,27 @@ +:import("custom-path.css") { + /* empty to check the order */ +} + +:import("./bb.css") { + somevalue: localvalue; +} + +.a { + composes: aa from './aa.css'; +} + +.b { + composes: bb from './bb.css'; + composes: bb from './aa.css'; +} + +.c { + composes: cc from './cc.css'; + composes: cc from './aa.css'; +} + +.d { + composes: dd from './cc.css'; + composes: dd from './bb.css'; + composes: dd from './dd.css'; +} diff --git a/test/topologicalSort.js b/test/topologicalSort.js new file mode 100644 index 0000000..e6cd1cb --- /dev/null +++ b/test/topologicalSort.js @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('assert'); +const topologicalSort = require('../src/topologicalSort'); + +const STRICT = true; + +describe('topologicalSort', () => { + it('should resolve graphs', () => { + const graph1 = { + v1: ['v2', 'v5'], + v2: [], + v3: ['v2', 'v4', 'v5'], + v4: [], + v5: [], + }; + + const graph2 = { + v1: ['v2', 'v5'], + v2: ['v4'], + v3: ['v2', 'v4', 'v5'], + v4: [], + v5: [], + }; + + assert.deepEqual(topologicalSort(graph1, STRICT), ['v2', 'v5', 'v1', 'v4', 'v3']); + assert.deepEqual(topologicalSort(graph2, STRICT), ['v4', 'v2', 'v5', 'v1', 'v3']); + }); + + it('should return exception if there is a cycle in the graph', () => { + const graph = { + v1: ['v3'], + v2: [], + v3: ['v1'], + }; + + const er = topologicalSort(graph, STRICT); + + assert.ok(er instanceof Error, 'Expected exception'); + assert.deepEqual(er.nodes, ['v1', 'v3']); + }); + + it('should resolve graph in non-strict mode', () => { + const graph = { + v1: ['v3'], + v2: [], + v3: ['v1'], + }; + + assert.deepEqual(topologicalSort(graph, !STRICT), ['v3', 'v1', 'v2']); + }); +});