Skip to content

Commit 1d6929a

Browse files
feat(index): add CSS Modules support (postcss-modules)
1 parent 6022083 commit 1d6929a

File tree

6 files changed

+699
-0
lines changed

6 files changed

+699
-0
lines changed

lib/index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const postcssrc = require('postcss-load-config')
1212

1313
const SyntaxError = require('./Error')
1414

15+
// DROP
16+
const modules = require('./modules')
17+
1518
/**
1619
* PostCSS Loader
1720
*
@@ -102,6 +105,10 @@ module.exports = function loader (css, map, meta) {
102105
if (config.options.from) delete config.options.from
103106

104107
let plugins = config.plugins || []
108+
109+
// DROP
110+
if (config.options.modules) plugins.concat(modules())
111+
105112
let options = Object.assign({
106113
to: file,
107114
from: file,

lib/modules/composes.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/* eslint-env node */
2+
import postcss from "postcss";
3+
import Tokenizer from "css-selector-tokenizer";
4+
import valueParser from "postcss-value-parser";
5+
import { extractICSS, createICSSRules } from "icss-utils";
6+
7+
const plugin = "postcss-icss-composes";
8+
9+
const flatten = outer => outer.reduce((acc, inner) => [...acc, ...inner], []);
10+
11+
const includes = (array, value) => array.indexOf(value) !== -1;
12+
13+
const isSingular = node => node.nodes.length === 1;
14+
15+
const isLocal = node =>
16+
node.type === "nested-pseudo-class" && node.name === "local";
17+
18+
const isClass = node => node.type === "class";
19+
20+
const getSelectorIdentifier = selector => {
21+
if (!isSingular(selector)) {
22+
return null;
23+
}
24+
const [node] = selector.nodes;
25+
if (isLocal(node)) {
26+
const local = node.nodes[0];
27+
if (isSingular(local) && isClass(local.nodes[0])) {
28+
return local.nodes[0].name;
29+
}
30+
return null;
31+
}
32+
if (isClass(node)) {
33+
return node.name;
34+
}
35+
return null;
36+
};
37+
38+
const getIdentifiers = (rule, result) => {
39+
const selectors = Tokenizer.parse(rule.selector).nodes;
40+
return selectors
41+
.map(selector => {
42+
const identifier = getSelectorIdentifier(selector);
43+
if (identifier === null) {
44+
result.warn(
45+
`composition is only allowed in single class selector, not in '${Tokenizer.stringify(selector)}'`,
46+
{ node: rule }
47+
);
48+
}
49+
return identifier;
50+
})
51+
.filter(identifier => identifier !== null);
52+
};
53+
54+
const isComposes = node =>
55+
node.type === "decl" &&
56+
(node.prop === "composes" || node.prop === "compose-with");
57+
58+
const walkRules = (css, callback) =>
59+
css.walkRules(rule => {
60+
if (rule.some(node => isComposes(node))) {
61+
callback(rule);
62+
}
63+
});
64+
65+
const walkDecls = (rule, callback) =>
66+
rule.each(node => {
67+
if (isComposes(node)) {
68+
callback(node);
69+
}
70+
});
71+
72+
const isMedia = node =>
73+
(node.type === "atrule" && node.name === "media") ||
74+
(node.parent && isMedia(node.parent));
75+
76+
const splitBy = (array, cond) =>
77+
array.reduce(
78+
(acc, item) =>
79+
cond(item)
80+
? [...acc, []]
81+
: [...acc.slice(0, -1), [...acc[acc.length - 1], item]],
82+
[[]]
83+
);
84+
85+
const parseComposes = value => {
86+
const parsed = valueParser(value);
87+
const [names, path] = splitBy(
88+
parsed.nodes,
89+
node => node.type === "word" && node.value === "from"
90+
);
91+
return {
92+
names: names.filter(node => node.type === "word").map(node => node.value),
93+
path: path &&
94+
path.filter(node => node.type === "string").map(node => node.value)[0]
95+
};
96+
};
97+
98+
const combineIntoMessages = (classes, composed) =>
99+
flatten(
100+
classes.map(name =>
101+
composed.map(value => ({
102+
plugin,
103+
type: "icss-composed",
104+
name,
105+
value
106+
}))
107+
)
108+
);
109+
110+
const invertObject = obj =>
111+
Object.keys(obj).reduce(
112+
(acc, key) => Object.assign({}, acc, { [obj[key]]: key }),
113+
{}
114+
);
115+
116+
const combineImports = (icss, composed) =>
117+
Object.keys(composed).reduce(
118+
(acc, path) =>
119+
Object.assign({}, acc, {
120+
[`'${path}'`]: Object.assign(
121+
{},
122+
acc[`'${path}'`],
123+
invertObject(composed[path])
124+
)
125+
}),
126+
Object.assign({}, icss)
127+
);
128+
129+
const convertMessagesToExports = (messages, aliases) =>
130+
messages
131+
.map(msg => msg.name)
132+
.reduce((acc, name) => (includes(acc, name) ? acc : [...acc, name]), [])
133+
.reduce(
134+
(acc, name) =>
135+
Object.assign({}, acc, {
136+
[name]: [
137+
aliases[name] || name,
138+
...messages
139+
.filter(msg => msg.name === name)
140+
.map(msg => aliases[msg.value] || msg.value)
141+
].join(" ")
142+
}),
143+
{}
144+
);
145+
146+
const getScopedClasses = messages =>
147+
messages
148+
.filter(msg => msg.type === "icss-scoped")
149+
.reduce(
150+
(acc, msg) => Object.assign({}, acc, { [msg.name]: msg.value }),
151+
{}
152+
);
153+
154+
module.exports = postcss.plugin(plugin, () => (css, result) => {
155+
const scopedClasses = getScopedClasses(result.messages);
156+
const composedMessages = [];
157+
const composedImports = {};
158+
159+
let importedIndex = 0;
160+
const getImportedName = (path, name) => {
161+
if (!composedImports[path]) {
162+
composedImports[path] = {};
163+
}
164+
if (composedImports[path][name]) {
165+
return composedImports[path][name];
166+
}
167+
const importedName = `__composed__${name}__${importedIndex}`;
168+
composedImports[path][name] = importedName;
169+
importedIndex += 1;
170+
return importedName;
171+
};
172+
173+
const { icssImports, icssExports } = extractICSS(css);
174+
175+
walkRules(css, rule => {
176+
const classes = getIdentifiers(rule, result);
177+
if (isMedia(rule)) {
178+
result.warn(
179+
"composition cannot be conditional and is not allowed in media queries",
180+
{ node: rule }
181+
);
182+
}
183+
walkDecls(rule, decl => {
184+
const { names, path } = parseComposes(decl.value);
185+
const composed = path
186+
? names.map(name => getImportedName(path, name))
187+
: names;
188+
composedMessages.push(...combineIntoMessages(classes, composed));
189+
decl.remove();
190+
});
191+
});
192+
193+
const composedExports = convertMessagesToExports(
194+
composedMessages,
195+
scopedClasses
196+
);
197+
const exports = Object.assign({}, icssExports, composedExports);
198+
const imports = combineImports(icssImports, composedImports);
199+
css.prepend(createICSSRules(imports, exports));
200+
result.messages.push(...composedMessages);
201+
});

lib/modules/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = (options) => [
2+
require('./modules/values')(),
3+
require('./modules/selectors')(),
4+
require('./modules/composes')(),
5+
require('./modules/keyframes')()
6+
]

lib/modules/keyframes.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/* eslint-env node */
2+
import postcss from "postcss";
3+
import valueParser from "postcss-value-parser";
4+
import { extractICSS, createICSSRules } from "icss-utils";
5+
6+
const plugin = "postcss-icss-keyframes";
7+
8+
const reserved = [
9+
"none",
10+
"inherited",
11+
"initial",
12+
"unset",
13+
/* single-timing-function */
14+
"linear",
15+
"ease",
16+
"ease-in",
17+
"ease-in-out",
18+
"ease-out",
19+
"step-start",
20+
"step-end",
21+
"start",
22+
"end",
23+
/* single-animation-iteration-count */
24+
"infinite",
25+
/* single-animation-direction */
26+
"normal",
27+
"reverse",
28+
"alternate",
29+
"alternate-reverse",
30+
/* single-animation-fill-mode */
31+
"forwards",
32+
"backwards",
33+
"both",
34+
/* single-animation-play-state */
35+
"running",
36+
"paused"
37+
];
38+
39+
const badNamePattern = /^[0-9]/;
40+
41+
const defaultGenerator = (name, path) => {
42+
const sanitized = path
43+
.replace(/^.*[\/\\]/, "")
44+
.replace(/[\W_]+/g, "_")
45+
.replace(/^_|_$/g, "");
46+
return `__${sanitized}__${name}`;
47+
};
48+
49+
const includes = (array, item) => array.indexOf(item) !== -1;
50+
51+
module.exports = postcss.plugin(plugin, (options = {}) => (css, result) => {
52+
const generateScopedName = options.generateScopedName || defaultGenerator;
53+
const { icssImports, icssExports } = extractICSS(css);
54+
const keyframesExports = Object.create(null);
55+
56+
css.walkAtRules(/keyframes$/, atrule => {
57+
const name = atrule.params;
58+
if (includes(reserved, name)) {
59+
return result.warn(`Unable to use reserve '${name}' animation name`, {
60+
node: atrule
61+
});
62+
}
63+
if (badNamePattern.test(name)) {
64+
return result.warn(`Invalid animation name identifier '${name}'`, {
65+
node: atrule
66+
});
67+
}
68+
if (icssExports[name]) {
69+
result.warn(
70+
`'${name}' identifier is already declared and will be override`,
71+
{ node: atrule }
72+
);
73+
}
74+
const alias =
75+
keyframesExports[name] ||
76+
generateScopedName(name, css.source.input.from, css.source.input.css);
77+
keyframesExports[name] = alias;
78+
atrule.params = alias;
79+
});
80+
81+
css.walkDecls(/animation$|animation-name$/, decl => {
82+
const parsed = valueParser(decl.value);
83+
parsed.nodes.forEach(node => {
84+
const alias = keyframesExports[node.value];
85+
if (node.type === "word" && Boolean(alias)) {
86+
node.value = alias;
87+
}
88+
});
89+
decl.value = parsed.toString();
90+
});
91+
92+
const exports = Object.assign(icssExports, keyframesExports);
93+
const messages = Object.keys(exports).map(name => ({
94+
plugin,
95+
type: "icss-scoped",
96+
name,
97+
value: icssExports[name]
98+
}));
99+
css.prepend(createICSSRules(icssImports, exports));
100+
result.messages.push(...messages);
101+
});

0 commit comments

Comments
 (0)