Skip to content

feat(index): add CSS Modules support (postcss-modules) #323

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -12,6 +12,9 @@ const postcssrc = require('postcss-load-config')

const SyntaxError = require('./Error')

// DROP
const modules = require('./modules')

/**
* PostCSS Loader
*
@@ -102,6 +105,10 @@ module.exports = function loader (css, map, meta) {
if (config.options.from) delete config.options.from

let plugins = config.plugins || []

// DROP
if (config.options.modules) plugins.concat(modules())

let options = Object.assign({
to: file,
from: file,
201 changes: 201 additions & 0 deletions lib/modules/composes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/* eslint-env node */
import postcss from "postcss";
import Tokenizer from "css-selector-tokenizer";
import valueParser from "postcss-value-parser";
import { extractICSS, createICSSRules } from "icss-utils";

const plugin = "postcss-icss-composes";

const flatten = outer => outer.reduce((acc, inner) => [...acc, ...inner], []);

const includes = (array, value) => array.indexOf(value) !== -1;

const isSingular = node => node.nodes.length === 1;

const isLocal = node =>
node.type === "nested-pseudo-class" && node.name === "local";

const isClass = node => node.type === "class";

const getSelectorIdentifier = selector => {
if (!isSingular(selector)) {
return null;
}
const [node] = selector.nodes;
if (isLocal(node)) {
const local = node.nodes[0];
if (isSingular(local) && isClass(local.nodes[0])) {
return local.nodes[0].name;
}
return null;
}
if (isClass(node)) {
return node.name;
}
return null;
};

const getIdentifiers = (rule, result) => {
const selectors = Tokenizer.parse(rule.selector).nodes;
return selectors
.map(selector => {
const identifier = getSelectorIdentifier(selector);
if (identifier === null) {
result.warn(
`composition is only allowed in single class selector, not in '${Tokenizer.stringify(selector)}'`,
{ node: rule }
);
}
return identifier;
})
.filter(identifier => identifier !== null);
};

const isComposes = node =>
node.type === "decl" &&
(node.prop === "composes" || node.prop === "compose-with");

const walkRules = (css, callback) =>
css.walkRules(rule => {
if (rule.some(node => isComposes(node))) {
callback(rule);
}
});

const walkDecls = (rule, callback) =>
rule.each(node => {
if (isComposes(node)) {
callback(node);
}
});

const isMedia = node =>
(node.type === "atrule" && node.name === "media") ||
(node.parent && isMedia(node.parent));

const splitBy = (array, cond) =>
array.reduce(
(acc, item) =>
cond(item)
? [...acc, []]
: [...acc.slice(0, -1), [...acc[acc.length - 1], item]],
[[]]
);

const parseComposes = value => {
const parsed = valueParser(value);
const [names, path] = splitBy(
parsed.nodes,
node => node.type === "word" && node.value === "from"
);
return {
names: names.filter(node => node.type === "word").map(node => node.value),
path: path &&
path.filter(node => node.type === "string").map(node => node.value)[0]
};
};

const combineIntoMessages = (classes, composed) =>
flatten(
classes.map(name =>
composed.map(value => ({
plugin,
type: "icss-composed",
name,
value
}))
)
);

const invertObject = obj =>
Object.keys(obj).reduce(
(acc, key) => Object.assign({}, acc, { [obj[key]]: key }),
{}
);

const combineImports = (icss, composed) =>
Object.keys(composed).reduce(
(acc, path) =>
Object.assign({}, acc, {
[`'${path}'`]: Object.assign(
{},
acc[`'${path}'`],
invertObject(composed[path])
)
}),
Object.assign({}, icss)
);

const convertMessagesToExports = (messages, aliases) =>
messages
.map(msg => msg.name)
.reduce((acc, name) => (includes(acc, name) ? acc : [...acc, name]), [])
.reduce(
(acc, name) =>
Object.assign({}, acc, {
[name]: [
aliases[name] || name,
...messages
.filter(msg => msg.name === name)
.map(msg => aliases[msg.value] || msg.value)
].join(" ")
}),
{}
);

const getScopedClasses = messages =>
messages
.filter(msg => msg.type === "icss-scoped")
.reduce(
(acc, msg) => Object.assign({}, acc, { [msg.name]: msg.value }),
{}
);

module.exports = postcss.plugin(plugin, () => (css, result) => {
const scopedClasses = getScopedClasses(result.messages);
const composedMessages = [];
const composedImports = {};

let importedIndex = 0;
const getImportedName = (path, name) => {
if (!composedImports[path]) {
composedImports[path] = {};
}
if (composedImports[path][name]) {
return composedImports[path][name];
}
const importedName = `__composed__${name}__${importedIndex}`;
composedImports[path][name] = importedName;
importedIndex += 1;
return importedName;
};

const { icssImports, icssExports } = extractICSS(css);

walkRules(css, rule => {
const classes = getIdentifiers(rule, result);
if (isMedia(rule)) {
result.warn(
"composition cannot be conditional and is not allowed in media queries",
{ node: rule }
);
}
walkDecls(rule, decl => {
const { names, path } = parseComposes(decl.value);
const composed = path
? names.map(name => getImportedName(path, name))
: names;
composedMessages.push(...combineIntoMessages(classes, composed));
decl.remove();
});
});

const composedExports = convertMessagesToExports(
composedMessages,
scopedClasses
);
const exports = Object.assign({}, icssExports, composedExports);
const imports = combineImports(icssImports, composedImports);
css.prepend(createICSSRules(imports, exports));
result.messages.push(...composedMessages);
});
6 changes: 6 additions & 0 deletions lib/modules/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = (options) => [
require('./modules/values')(),
require('./modules/selectors')(),
require('./modules/composes')(),
require('./modules/keyframes')()
]
101 changes: 101 additions & 0 deletions lib/modules/keyframes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* eslint-env node */
import postcss from "postcss";
import valueParser from "postcss-value-parser";
import { extractICSS, createICSSRules } from "icss-utils";

const plugin = "postcss-icss-keyframes";

const reserved = [
"none",
"inherited",
"initial",
"unset",
/* single-timing-function */
"linear",
"ease",
"ease-in",
"ease-in-out",
"ease-out",
"step-start",
"step-end",
"start",
"end",
/* single-animation-iteration-count */
"infinite",
/* single-animation-direction */
"normal",
"reverse",
"alternate",
"alternate-reverse",
/* single-animation-fill-mode */
"forwards",
"backwards",
"both",
/* single-animation-play-state */
"running",
"paused"
];

const badNamePattern = /^[0-9]/;

const defaultGenerator = (name, path) => {
const sanitized = path
.replace(/^.*[\/\\]/, "")
.replace(/[\W_]+/g, "_")
.replace(/^_|_$/g, "");
return `__${sanitized}__${name}`;
};

const includes = (array, item) => array.indexOf(item) !== -1;

module.exports = postcss.plugin(plugin, (options = {}) => (css, result) => {
const generateScopedName = options.generateScopedName || defaultGenerator;
const { icssImports, icssExports } = extractICSS(css);
const keyframesExports = Object.create(null);

css.walkAtRules(/keyframes$/, atrule => {
const name = atrule.params;
if (includes(reserved, name)) {
return result.warn(`Unable to use reserve '${name}' animation name`, {
node: atrule
});
}
if (badNamePattern.test(name)) {
return result.warn(`Invalid animation name identifier '${name}'`, {
node: atrule
});
}
if (icssExports[name]) {
result.warn(
`'${name}' identifier is already declared and will be override`,
{ node: atrule }
);
}
const alias =
keyframesExports[name] ||
generateScopedName(name, css.source.input.from, css.source.input.css);
keyframesExports[name] = alias;
atrule.params = alias;
});

css.walkDecls(/animation$|animation-name$/, decl => {
const parsed = valueParser(decl.value);
parsed.nodes.forEach(node => {
const alias = keyframesExports[node.value];
if (node.type === "word" && Boolean(alias)) {
node.value = alias;
}
});
decl.value = parsed.toString();
});

const exports = Object.assign(icssExports, keyframesExports);
const messages = Object.keys(exports).map(name => ({
plugin,
type: "icss-scoped",
name,
value: icssExports[name]
}));
css.prepend(createICSSRules(icssImports, exports));
result.messages.push(...messages);
});
205 changes: 205 additions & 0 deletions lib/modules/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/* eslint-env node */
import postcss from "postcss";
import Tokenizer from "css-selector-tokenizer";
import { extractICSS, createICSSRules } from "icss-utils";
import genericNames from "generic-names";
import fromPairs from "lodash/fromPairs";

const plugin = "postcss-icss-selectors";

const trimNodes = nodes => {
const firstIndex = nodes.findIndex(node => node.type !== "spacing");
const lastIndex = nodes
.slice()
.reverse()
.findIndex(node => node.type !== "spacing");
return nodes.slice(firstIndex, nodes.length - lastIndex);
};

const isSpacing = node => node.type === "spacing" || node.type === "operator";

const isModifier = node =>
node.type === "pseudo-class" &&
(node.name === "local" || node.name === "global");

function localizeNode(node, { mode, inside, getAlias }) {
const newNodes = node.nodes.reduce((acc, n, index, nodes) => {
switch (n.type) {
case "spacing":
if (isModifier(nodes[index + 1])) {
return [...acc, Object.assign({}, n, { value: "" })];
}
return [...acc, n];

case "operator":
if (isModifier(nodes[index + 1])) {
return [...acc, Object.assign({}, n, { after: "" })];
}
return [...acc, n];

case "pseudo-class":
if (isModifier(n)) {
if (inside) {
throw Error(
`A :${n.name} is not allowed inside of a :${inside}(...)`
);
}
if (index !== 0 && !isSpacing(nodes[index - 1])) {
throw Error(`Missing whitespace before :${n.name}`);
}
if (index !== nodes.length - 1 && !isSpacing(nodes[index + 1])) {
throw Error(`Missing whitespace after :${n.name}`);
}
// set mode
mode = n.name;
return acc;
}
return [...acc, n];

case "nested-pseudo-class":
if (n.name === "local" || n.name === "global") {
if (inside) {
throw Error(
`A :${n.name}(...) is not allowed inside of a :${inside}(...)`
);
}
return [
...acc,
...localizeNode(n.nodes[0], {
mode: n.name,
inside: n.name,
getAlias
}).nodes
];
} else {
return [
...acc,
Object.assign({}, n, {
nodes: localizeNode(n.nodes[0], { mode, inside, getAlias }).nodes
})
];
}

case "id":
case "class":
if (mode === "local") {
return [...acc, Object.assign({}, n, { name: getAlias(n.name) })];
}
return [...acc, n];

default:
return [...acc, n];
}
}, []);

return Object.assign({}, node, { nodes: trimNodes(newNodes) });
}

const localizeSelectors = (selectors, mode, getAlias) => {
const node = Tokenizer.parse(selectors);
return Tokenizer.stringify(
Object.assign({}, node, {
nodes: node.nodes.map(n => localizeNode(n, { mode, getAlias }))
})
);
};

const walkRules = (css, callback) => {
css.walkRules(rule => {
if (rule.parent.type !== "atrule" || !/keyframes$/.test(rule.parent.name)) {
callback(rule);
}
});
};

const getMessages = aliases =>
Object.keys(aliases).map(name => ({
plugin,
type: "icss-scoped",
name,
value: aliases[name]
}));

const getValue = (messages, name) =>
messages.find(msg => msg.type === "icss-value" && msg.value === name);

const isRedeclared = (messages, name) =>
messages.find(msg => msg.type === "icss-scoped" && msg.name === name);

const flatten = array => array.reduce((acc, item) => [...acc, ...item], []);

const getComposed = (name, messages, root) => [
name,
...flatten(
messages
.filter(msg => msg.name === name && msg.value !== root)
.map(msg => getComposed(msg.value, messages, root))
)
];

const composeAliases = (aliases, messages) =>
Object.keys(aliases).reduce(
(acc, name) =>
Object.assign({}, acc, {
[name]: getComposed(name, messages, name)
.map(value => aliases[value] || value)
.join(" ")
}),
{}
);

const mapMessages = (messages, type) =>
fromPairs(
messages.filter(msg => msg.type === type).map(msg => [msg.name, msg.value])
);

const composeExports = messages => {
const composed = messages.filter(msg => msg.type === "icss-composed");
const values = mapMessages(messages, "icss-value");
const scoped = mapMessages(messages, "icss-scoped");
const aliases = Object.assign({}, scoped, values);
return composeAliases(aliases, composed);
};

module.exports = postcss.plugin(plugin, (options = {}) => (css, result) => {
const { icssImports, icssExports } = extractICSS(css);
const generateScopedName =
options.generateScopedName ||
genericNames("[name]__[local]---[hash:base64:5]");
const input = (css && css.source && css.source.input) || {};
const aliases = {};
walkRules(css, rule => {
const getAlias = name => {
if (aliases[name]) {
return aliases[name];
}
// icss-value contract
const valueMsg = getValue(result.messages, name);
if (valueMsg) {
aliases[valueMsg.name] = name;
return name;
}
const alias = generateScopedName(name, input.from, input.css);
aliases[name] = alias;
// icss-scoped contract
if (isRedeclared(result.messages, name)) {
result.warn(`'${name}' already declared`, { node: rule });
}
return alias;
};
try {
rule.selector = localizeSelectors(
rule.selector,
options.mode === "global" ? "global" : "local",
getAlias
);
} catch (e) {
throw rule.error(e.message);
}
});
result.messages.push(...getMessages(aliases));
// contracts
const composedExports = composeExports(result.messages);
const exports = Object.assign({}, icssExports, composedExports);
css.prepend(createICSSRules(icssImports, exports));
});
179 changes: 179 additions & 0 deletions lib/modules/values.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/* eslint-env node */
import postcss from "postcss";
import valueParser from "postcss-value-parser";
import {
replaceSymbols,
replaceValueSymbols,
extractICSS,
createICSSRules
} from "icss-utils";
import findLastIndex from "lodash/findLastIndex";
import dropWhile from "lodash/dropWhile";
import dropRightWhile from "lodash/dropRightWhile";
import fromPairs from "lodash/fromPairs";

const plugin = "postcss-modules-values";

const chunkBy = (collection, iteratee) =>
collection.reduce(
(acc, item) =>
iteratee(item)
? [...acc, []]
: [...acc.slice(0, -1), [...acc[acc.length - 1], item]],
[[]]
);

const isWord = node => node.type === "word";

const isDiv = node => node.type === "div";

const isSpace = node => node.type === "space";

const isNotSpace = node => !isSpace(node);

const isFromWord = node => isWord(node) && node.value === "from";

const isAsWord = node => isWord(node) && node.value === "as";

const isComma = node => isDiv(node) && node.value === ",";

const isColon = node => isDiv(node) && node.value === ":";

const isInitializer = node => isColon(node) || isSpace(node);

const trimNodes = nodes => dropWhile(dropRightWhile(nodes, isSpace), isSpace);

const getPathValue = nodes =>
nodes.length === 1 && nodes[0].type === "string" ? nodes[0].value : null;

const expandValuesParentheses = nodes =>
nodes.length === 1 && nodes[0].type === "function" && nodes[0].value === ""
? nodes[0].nodes
: nodes;

const getAliasesPairs = valuesNodes =>
chunkBy(expandValuesParentheses(valuesNodes), isComma).map(pairNodes => {
const nodes = pairNodes.filter(isNotSpace);
if (nodes.length === 1 && isWord(nodes[0])) {
return [nodes[0].value, nodes[0].value];
}
if (
nodes.length === 3 &&
isWord(nodes[0]) &&
isAsWord(nodes[1]) &&
isWord(nodes[2])
) {
return [nodes[0].value, nodes[2].value];
}
return null;
});

const parse = value => {
const parsed = valueParser(value).nodes;
const fromIndex = findLastIndex(parsed, isFromWord);
if (fromIndex === -1) {
if (parsed.length > 2 && isWord(parsed[0]) && isInitializer(parsed[1])) {
return {
type: "value",
name: parsed[0].value,
value: valueParser.stringify(trimNodes(parsed.slice(2)))
};
}
return null;
}
const pairs = getAliasesPairs(trimNodes(parsed.slice(0, fromIndex)));
const path = getPathValue(trimNodes(parsed.slice(fromIndex + 1)));
if (pairs.every(Boolean) && path) {
return {
type: "import",
pairs,
path
};
}
return null;
};

const isForbidden = name => name.includes(".") || name.includes("#");

const createGenerator = (i = 0) => name =>
`__value__${name.replace(/\W/g, "_")}__${i++}`;

const getScopedAliases = (messages, values) =>
fromPairs(
messages
.filter(msg => msg.type === "icss-scoped")
.map(msg => [msg.value, values[msg.name]])
);

const getMessages = exports =>
Object.keys(exports).map(name => ({
plugin: "postcss-icss-values",
type: "icss-value",
name,
value: exports[name]
}));

module.exports = postcss.plugin(plugin, () => (css, result) => {
const { icssImports, icssExports } = extractICSS(css);
const valuesExports = {};
const getAliasName = createGenerator();
const addExports = (node, name, value) => {
if (isForbidden(name)) {
result.warn(`Dot and hash symbols are not allowed in value "${name}"`, {
node
});
}
if (valuesExports[name]) {
result.warn(`"${name}" value already declared`, { node });
}
valuesExports[name] = replaceValueSymbols(value, valuesExports);
};

css.walkAtRules("value", atrule => {
if (atrule.params.indexOf("@value") !== -1) {
result.warn(`Invalid value definition "${atrule.params}"`, {
node: atrule
});
} else {
const parsed = parse(atrule.params);
if (parsed) {
if (parsed.type === "value") {
const { name, value } = parsed;
addExports(atrule, name, value);
}
if (parsed.type === "import") {
const pairs = parsed.pairs.map(([imported, local]) => {
const alias = getAliasName(local);
addExports(atrule, local, alias);
return [alias, imported];
});
const aliases = fromPairs(pairs);
icssImports[parsed.path] = Object.assign(
{},
icssImports[parsed.path],
aliases
);
}
} else {
result.warn(`Invalid value definition "${atrule.params}"`, {
node: atrule
});
}
}
atrule.remove();
});

const scopedAliases = getScopedAliases(result.messages, valuesExports);

replaceSymbols(css, Object.assign({}, valuesExports, scopedAliases));

Object.keys(icssExports).forEach(key => {
icssExports[key] = replaceValueSymbols(icssExports[key], scopedAliases);
});

css.prepend(
createICSSRules(icssImports, Object.assign({}, icssExports, valuesExports))
);

result.messages.push(...getMessages(valuesExports));
});