Skip to content

Commit 7f4028c

Browse files
feature: predictable imports order (#138)
* predictable imports order * test cases * added visited graph to handle copies * fallback for nodejs 4 * testcase for duplicates * graph description * version 1.2.0
1 parent c2c40a2 commit 7f4028c

File tree

12 files changed

+416
-48
lines changed

12 files changed

+416
-48
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
node_modules
1+
.DS_Store
22
coverage
33
lib
4+
node_modules
5+
yarn.lock

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "postcss-modules-extract-imports",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"description": "A CSS Modules transform to extract local aliases for inline imports",
55
"main": "lib/index.js",
66
"scripts": {

src/index.js

Lines changed: 146 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,169 @@
11
import postcss from 'postcss';
2+
import topologicalSort from './topologicalSort';
23

3-
const declWhitelist = ['composes'],
4-
declFilter = new RegExp( `^(${declWhitelist.join( '|' )})$` ),
5-
matchImports = /^(.+?)\s+from\s+(?:"([^"]+)"|'([^']+)'|(global))$/,
6-
icssImport = /^:import\((?:"([^"]+)"|'([^']+)')\)/;
4+
const declWhitelist = ['composes'];
5+
const declFilter = new RegExp( `^(${declWhitelist.join( '|' )})$` );
6+
const matchImports = /^(.+?)\s+from\s+(?:"([^"]+)"|'([^']+)'|(global))$/;
7+
const icssImport = /^:import\((?:"([^"]+)"|'([^']+)')\)/;
78

8-
const processor = postcss.plugin( 'modules-extract-imports', function ( options ) {
9-
return ( css ) => {
10-
let imports = {},
11-
importIndex = 0,
12-
createImportedName = options && options.createImportedName || (( importName/*, path*/ ) => `i__imported_${importName.replace( /\W/g, '_' )}_${importIndex++}`);
9+
const VISITED_MARKER = 1;
10+
11+
function createParentName(rule, root) {
12+
return `__${root.index(rule.parent)}_${rule.selector}`;
13+
}
14+
15+
function serializeImports(imports) {
16+
return imports.map(importPath => '`' + importPath + '`').join(', ');
17+
}
18+
19+
/**
20+
* :import('G') {}
21+
*
22+
* Rule
23+
* composes: ... from 'A'
24+
* composes: ... from 'B'
25+
26+
* Rule
27+
* composes: ... from 'A'
28+
* composes: ... from 'A'
29+
* composes: ... from 'C'
30+
*
31+
* Results in:
32+
*
33+
* graph: {
34+
* G: [],
35+
* A: [],
36+
* B: ['A'],
37+
* C: ['A'],
38+
* }
39+
*/
40+
function addImportToGraph(importId, parentId, graph, visited) {
41+
const siblingsId = parentId + '_' + 'siblings';
42+
const visitedId = parentId + '_' + importId;
43+
44+
if (visited[visitedId] !== VISITED_MARKER) {
45+
if (!Array.isArray(visited[siblingsId])) visited[siblingsId] = [];
46+
47+
const siblings = visited[siblingsId];
48+
49+
if (Array.isArray(graph[importId]))
50+
graph[importId] = graph[importId].concat(siblings);
51+
else
52+
graph[importId] = siblings.slice();
53+
54+
visited[visitedId] = VISITED_MARKER;
55+
siblings.push(importId);
56+
}
57+
}
58+
59+
const processor = postcss.plugin('modules-extract-imports', function (options = {}) {
60+
const failOnWrongOrder = options.failOnWrongOrder;
61+
62+
return css => {
63+
const graph = {};
64+
const visited = {};
65+
66+
const existingImports = {};
67+
const importDecls = {};
68+
const imports = {};
69+
70+
let importIndex = 0;
71+
72+
const createImportedName = typeof options.createImportedName !== 'function'
73+
? (importName/*, path*/) => `i__imported_${importName.replace(/\W/g, '_')}_${importIndex++}`
74+
: options.createImportedName;
75+
76+
// Check the existing imports order and save refs
77+
css.walkRules(rule => {
78+
const matches = icssImport.exec(rule.selector);
79+
80+
if (matches) {
81+
const [/*match*/, doubleQuotePath, singleQuotePath] = matches;
82+
const importPath = doubleQuotePath || singleQuotePath;
83+
84+
addImportToGraph(importPath, 'root', graph, visited);
85+
86+
existingImports[importPath] = rule;
87+
}
88+
});
1389

1490
// Find any declaration that supports imports
15-
css.walkDecls( declFilter, ( decl ) => {
16-
let matches = decl.value.match( matchImports );
91+
css.walkDecls(declFilter, decl => {
92+
let matches = decl.value.match(matchImports);
1793
let tmpSymbols;
18-
if ( matches ) {
94+
95+
if (matches) {
1996
let [/*match*/, symbols, doubleQuotePath, singleQuotePath, global] = matches;
97+
2098
if (global) {
2199
// Composing globals simply means changing these classes to wrap them in global(name)
22-
tmpSymbols = symbols.split(/\s+/).map(s => `global(${s})`)
100+
tmpSymbols = symbols.split(/\s+/).map(s => `global(${s})`);
23101
} else {
24-
let path = doubleQuotePath || singleQuotePath;
25-
imports[path] = imports[path] || {};
26-
tmpSymbols = symbols.split(/\s+/)
27-
.map(s => {
28-
if (!imports[path][s]) {
29-
imports[path][s] = createImportedName(s, path);
30-
}
31-
return imports[path][s];
32-
});
102+
const importPath = doubleQuotePath || singleQuotePath;
103+
const parentRule = createParentName(decl.parent, css);
104+
105+
addImportToGraph(importPath, parentRule, graph, visited);
106+
107+
importDecls[importPath] = decl;
108+
imports[importPath] = imports[importPath] || {};
109+
110+
tmpSymbols = symbols.split(/\s+/).map(s => {
111+
if (!imports[importPath][s]) {
112+
imports[importPath][s] = createImportedName(s, importPath);
113+
}
114+
115+
return imports[importPath][s];
116+
});
33117
}
34-
decl.value = tmpSymbols.join( ' ' );
35-
}
36-
} );
37118

38-
// If we've found any imports, insert or append :import rules
39-
let existingImports = {};
40-
css.walkRules(rule => {
41-
let matches = icssImport.exec(rule.selector);
42-
if (matches) {
43-
let [/*match*/, doubleQuotePath, singleQuotePath] = matches;
44-
existingImports[doubleQuotePath || singleQuotePath] = rule;
119+
decl.value = tmpSymbols.join(' ');
45120
}
46121
});
47122

48-
Object.keys( imports ).reverse().forEach( path => {
123+
const importsOrder = topologicalSort(graph, failOnWrongOrder);
124+
125+
if (importsOrder instanceof Error) {
126+
const importPath = importsOrder.nodes.find(importPath => importDecls.hasOwnProperty(importPath));
127+
const decl = importDecls[importPath];
128+
129+
const errMsg = 'Failed to resolve order of composed modules ' + serializeImports(importsOrder.nodes) + '.';
49130

131+
throw decl.error(errMsg, {
132+
plugin: 'modules-extract-imports',
133+
word: 'composes',
134+
});
135+
}
136+
137+
let lastImportRule;
138+
importsOrder.forEach(path => {
139+
const importedSymbols = imports[path];
50140
let rule = existingImports[path];
51-
if (!rule) {
52-
rule = postcss.rule( {
141+
142+
if (!rule && importedSymbols) {
143+
rule = postcss.rule({
53144
selector: `:import("${path}")`,
54-
raws: { after: "\n" }
55-
} );
56-
css.prepend( rule );
145+
raws: {after: '\n'},
146+
});
147+
148+
if (lastImportRule)
149+
css.insertAfter(lastImportRule, rule);
150+
else
151+
css.prepend(rule);
57152
}
58-
Object.keys( imports[path] ).forEach( importedSymbol => {
59-
rule.append(postcss.decl( {
153+
154+
lastImportRule = rule;
155+
156+
if (!importedSymbols) return;
157+
158+
Object.keys(importedSymbols).forEach(importedSymbol => {
159+
rule.append(postcss.decl({
60160
value: importedSymbol,
61-
prop: imports[path][importedSymbol],
62-
raws: { before: "\n " }
63-
} ) );
64-
} );
65-
} );
161+
prop: importedSymbols[importedSymbol],
162+
raws: {before: '\n '},
163+
}));
164+
});
165+
});
66166
};
67-
} );
167+
});
68168

69169
export default processor;

src/topologicalSort.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const PERMANENT_MARKER = 2;
2+
const TEMPORARY_MARKER = 1;
3+
4+
function createError(node, graph) {
5+
const er = new Error('Nondeterministic import\'s order');
6+
7+
const related = graph[node];
8+
const relatedNode = related.find(relatedNode => graph[relatedNode].indexOf(node) > -1);
9+
10+
er.nodes = [node, relatedNode];
11+
12+
return er;
13+
}
14+
15+
function walkGraph(node, graph, state, result, strict) {
16+
if (state[node] === PERMANENT_MARKER) return;
17+
if (state[node] === TEMPORARY_MARKER) {
18+
if (strict) return createError(node, graph);
19+
return;
20+
}
21+
22+
state[node] = TEMPORARY_MARKER;
23+
24+
const children = graph[node];
25+
const length = children.length;
26+
27+
for (let i = 0; i < length; ++i) {
28+
const er = walkGraph(children[i], graph, state, result, strict);
29+
if (er instanceof Error) return er;
30+
}
31+
32+
state[node] = PERMANENT_MARKER;
33+
34+
result.push(node);
35+
}
36+
37+
export default function topologicalSort(graph, strict) {
38+
const result = [];
39+
const state = {};
40+
41+
const nodes = Object.keys(graph);
42+
const length = nodes.length;
43+
44+
for (let i = 0; i < length; ++i) {
45+
const er = walkGraph(nodes[i], graph, state, result, strict);
46+
if (er instanceof Error) return er;
47+
}
48+
49+
return result;
50+
}

test/check-import-order.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
const postcss = require('postcss');
5+
const processor = require('../');
6+
7+
describe('check-import-order', () => {
8+
let pipeline;
9+
10+
beforeEach(() => {
11+
pipeline = postcss([
12+
processor({failOnWrongOrder: true}),
13+
]);
14+
});
15+
16+
it('should throw an exception', () => {
17+
const input = `
18+
.aa {
19+
composes: b from './b.css';
20+
composes: c from './c.css';
21+
}
22+
23+
.bb {
24+
composes: c from './c.css';
25+
composes: b from './b.css';
26+
}
27+
`;
28+
29+
assert.throws(() => pipeline.process(input).css,
30+
/Failed to resolve order of composed modules/);
31+
});
32+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
:import("./b.css") {
2+
i__imported_b_1: b;
3+
}
4+
5+
:import("./c.css") {
6+
i__imported_c_0: c;
7+
}
8+
9+
.a {
10+
composes: i__imported_c_0;
11+
color: #bebebe;
12+
}
13+
14+
.b {
15+
/* `b` should be after `c` */
16+
composes: i__imported_b_1;
17+
composes: i__imported_c_0;
18+
color: #aaa;
19+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.a {
2+
composes: c from "./c.css";
3+
color: #bebebe;
4+
}
5+
6+
.b {
7+
/* `b` should be after `c` */
8+
composes: b from "./b.css";
9+
composes: c from "./c.css";
10+
color: #aaa;
11+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
:import("./aa.css") {
2+
i__imported_a_0: a;
3+
}
4+
5+
:import("./bb.css") {
6+
i__imported_b_1: b;
7+
}
8+
9+
:import("./cc.css") {
10+
smthing: somevalue;
11+
i__imported_c_2: c;
12+
}
13+
14+
.a {
15+
composes: i__imported_a_0;
16+
composes: i__imported_b_1;
17+
composes: i__imported_c_2;
18+
composes: i__imported_a_0;
19+
composes: i__imported_c_2;
20+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
:import("./cc.css") {
2+
smthing: somevalue;
3+
}
4+
5+
.a {
6+
composes: a from './aa.css';
7+
composes: b from './bb.css';
8+
composes: c from './cc.css';
9+
composes: a from './aa.css';
10+
composes: c from './cc.css';
11+
}

0 commit comments

Comments
 (0)