Skip to content

Commit

Permalink
feature: predictable imports order (#138)
Browse files Browse the repository at this point in the history
* predictable imports order

* test cases

* added visited graph to handle copies

* fallback for nodejs 4

* testcase for duplicates

* graph description

* version 1.2.0
  • Loading branch information
mightyaleksey authored May 22, 2017
1 parent c2c40a2 commit 7f4028c
Show file tree
Hide file tree
Showing 12 changed files with 416 additions and 48 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
.DS_Store
coverage
lib
node_modules
yarn.lock
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
192 changes: 146 additions & 46 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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;
50 changes: 50 additions & 0 deletions src/topologicalSort.js
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions test/check-import-order.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
19 changes: 19 additions & 0 deletions test/test-cases/resolve-composes-order/expected.css
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions test/test-cases/resolve-composes-order/source.css
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions test/test-cases/resolve-duplicates/expected.css
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions test/test-cases/resolve-duplicates/source.css
Original file line number Diff line number Diff line change
@@ -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';
}
Loading

0 comments on commit 7f4028c

Please sign in to comment.