diff --git a/lib/index.js b/lib/index.js index e3feb40d..795b9b20 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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, diff --git a/lib/modules/composes.js b/lib/modules/composes.js new file mode 100644 index 00000000..337fda9f --- /dev/null +++ b/lib/modules/composes.js @@ -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); +}); diff --git a/lib/modules/index.js b/lib/modules/index.js new file mode 100644 index 00000000..5195c4cdc --- /dev/null +++ b/lib/modules/index.js @@ -0,0 +1,6 @@ +module.exports = (options) => [ + require('./modules/values')(), + require('./modules/selectors')(), + require('./modules/composes')(), + require('./modules/keyframes')() +] diff --git a/lib/modules/keyframes.js b/lib/modules/keyframes.js new file mode 100644 index 00000000..e0aac38e --- /dev/null +++ b/lib/modules/keyframes.js @@ -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); +}); diff --git a/lib/modules/selectors.js b/lib/modules/selectors.js new file mode 100644 index 00000000..33ef4dde --- /dev/null +++ b/lib/modules/selectors.js @@ -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)); +}); diff --git a/lib/modules/values.js b/lib/modules/values.js new file mode 100644 index 00000000..f576777c --- /dev/null +++ b/lib/modules/values.js @@ -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)); +});