| 'use strict'; |
| |
| const postcss = require('postcss'); |
| const selectorParser = require('postcss-selector-parser'); |
| const valueParser = require('postcss-value-parser'); |
| const { extractICSS } = require('icss-utils'); |
| |
| const isSpacing = node => node.type === 'combinator' && node.value === ' '; |
| |
| function getImportLocalAliases(icssImports) { |
| const localAliases = new Map(); |
| Object.keys(icssImports).forEach(key => { |
| Object.keys(icssImports[key]).forEach(prop => { |
| localAliases.set(prop, icssImports[key][prop]); |
| }); |
| }); |
| return localAliases; |
| } |
| |
| function maybeLocalizeValue(value, localAliasMap) { |
| if (localAliasMap.has(value)) return value; |
| } |
| |
| function normalizeNodeArray(nodes) { |
| const array = []; |
| |
| nodes.forEach(function(x) { |
| if (Array.isArray(x)) { |
| normalizeNodeArray(x).forEach(function(item) { |
| array.push(item); |
| }); |
| } else if (x) { |
| array.push(x); |
| } |
| }); |
| |
| if (array.length > 0 && isSpacing(array[array.length - 1])) { |
| array.pop(); |
| } |
| return array; |
| } |
| |
| function localizeNode(rule, mode, localAliasMap) { |
| const isScopePseudo = node => |
| node.value === ':local' || node.value === ':global'; |
| |
| const transform = (node, context) => { |
| if (context.ignoreNextSpacing && !isSpacing(node)) { |
| throw new Error('Missing whitespace after ' + context.ignoreNextSpacing); |
| } |
| if (context.enforceNoSpacing && isSpacing(node)) { |
| throw new Error('Missing whitespace before ' + context.enforceNoSpacing); |
| } |
| |
| let newNodes; |
| switch (node.type) { |
| case 'root': { |
| let resultingGlobal; |
| |
| context.hasPureGlobals = false; |
| |
| newNodes = node.nodes.map(function(n) { |
| const nContext = { |
| global: context.global, |
| lastWasSpacing: true, |
| hasLocals: false, |
| explicit: false, |
| }; |
| |
| n = transform(n, nContext); |
| |
| if (typeof resultingGlobal === 'undefined') { |
| resultingGlobal = nContext.global; |
| } else if (resultingGlobal !== nContext.global) { |
| throw new Error( |
| 'Inconsistent rule global/local result in rule "' + |
| node + |
| '" (multiple selectors must result in the same mode for the rule)' |
| ); |
| } |
| |
| if (!nContext.hasLocals) { |
| context.hasPureGlobals = true; |
| } |
| |
| return n; |
| }); |
| |
| context.global = resultingGlobal; |
| |
| node.nodes = normalizeNodeArray(newNodes); |
| break; |
| } |
| case 'selector': { |
| newNodes = node.map(childNode => transform(childNode, context)); |
| |
| node = node.clone(); |
| node.nodes = normalizeNodeArray(newNodes); |
| break; |
| } |
| case 'combinator': { |
| if (isSpacing(node)) { |
| if (context.ignoreNextSpacing) { |
| context.ignoreNextSpacing = false; |
| context.lastWasSpacing = false; |
| context.enforceNoSpacing = false; |
| return null; |
| } |
| context.lastWasSpacing = true; |
| return node; |
| } |
| break; |
| } |
| case 'pseudo': { |
| let childContext; |
| const isNested = !!node.length; |
| const isScoped = isScopePseudo(node); |
| |
| // :local(.foo) |
| if (isNested) { |
| if (isScoped) { |
| if (node.nodes.length === 0) { |
| throw new Error(`${node.value}() can't be empty`); |
| } |
| |
| if (context.inside) { |
| throw new Error( |
| `A ${node.value} is not allowed inside of a ${ |
| context.inside |
| }(...)` |
| ); |
| } |
| |
| childContext = { |
| global: node.value === ':global', |
| inside: node.value, |
| hasLocals: false, |
| explicit: true, |
| }; |
| |
| newNodes = node |
| .map(childNode => transform(childNode, childContext)) |
| .reduce((acc, next) => acc.concat(next.nodes), []); |
| |
| if (newNodes.length) { |
| const { before, after } = node.spaces; |
| |
| const first = newNodes[0]; |
| const last = newNodes[newNodes.length - 1]; |
| |
| first.spaces = { before, after: first.spaces.after }; |
| last.spaces = { before: last.spaces.before, after }; |
| } |
| |
| node = newNodes; |
| |
| break; |
| } else { |
| childContext = { |
| global: context.global, |
| inside: context.inside, |
| lastWasSpacing: true, |
| hasLocals: false, |
| explicit: context.explicit, |
| }; |
| newNodes = node.map(childNode => |
| transform(childNode, childContext) |
| ); |
| |
| node = node.clone(); |
| node.nodes = normalizeNodeArray(newNodes); |
| |
| if (childContext.hasLocals) { |
| context.hasLocals = true; |
| } |
| } |
| break; |
| |
| //:local .foo .bar |
| } else if (isScoped) { |
| if (context.inside) { |
| throw new Error( |
| `A ${node.value} is not allowed inside of a ${ |
| context.inside |
| }(...)` |
| ); |
| } |
| |
| const addBackSpacing = !!node.spaces.before; |
| |
| context.ignoreNextSpacing = context.lastWasSpacing |
| ? node.value |
| : false; |
| |
| context.enforceNoSpacing = context.lastWasSpacing |
| ? false |
| : node.value; |
| |
| context.global = node.value === ':global'; |
| context.explicit = true; |
| |
| // because this node has spacing that is lost when we remove it |
| // we make up for it by adding an extra combinator in since adding |
| // spacing on the parent selector doesn't work |
| return addBackSpacing |
| ? selectorParser.combinator({ value: ' ' }) |
| : null; |
| } |
| break; |
| } |
| case 'id': |
| case 'class': { |
| if (!node.value) { |
| throw new Error('Invalid class or id selector syntax'); |
| } |
| |
| if (context.global) { |
| break; |
| } |
| |
| const isImportedValue = localAliasMap.has(node.value); |
| const isImportedWithExplicitScope = isImportedValue && context.explicit; |
| |
| if (!isImportedValue || isImportedWithExplicitScope) { |
| const innerNode = node.clone(); |
| innerNode.spaces = { before: '', after: '' }; |
| |
| node = selectorParser.pseudo({ |
| value: ':local', |
| nodes: [innerNode], |
| spaces: node.spaces, |
| }); |
| |
| context.hasLocals = true; |
| } |
| |
| break; |
| } |
| } |
| |
| context.lastWasSpacing = false; |
| context.ignoreNextSpacing = false; |
| context.enforceNoSpacing = false; |
| |
| return node; |
| }; |
| |
| const rootContext = { |
| global: mode === 'global', |
| hasPureGlobals: false, |
| }; |
| |
| rootContext.selector = selectorParser(root => { |
| transform(root, rootContext); |
| }).processSync(rule, { updateSelector: false, lossless: true }); |
| |
| return rootContext; |
| } |
| |
| function localizeDeclNode(node, context) { |
| switch (node.type) { |
| case 'word': |
| if (context.localizeNextItem) { |
| if (!context.localAliasMap.has(node.value)) { |
| node.value = ':local(' + node.value + ')'; |
| context.localizeNextItem = false; |
| } |
| } |
| break; |
| |
| case 'function': |
| if ( |
| context.options && |
| context.options.rewriteUrl && |
| node.value.toLowerCase() === 'url' |
| ) { |
| node.nodes.map(nestedNode => { |
| if (nestedNode.type !== 'string' && nestedNode.type !== 'word') { |
| return; |
| } |
| |
| let newUrl = context.options.rewriteUrl( |
| context.global, |
| nestedNode.value |
| ); |
| |
| switch (nestedNode.type) { |
| case 'string': |
| if (nestedNode.quote === "'") { |
| newUrl = newUrl.replace(/(\\)/g, '\\$1').replace(/'/g, "\\'"); |
| } |
| |
| if (nestedNode.quote === '"') { |
| newUrl = newUrl.replace(/(\\)/g, '\\$1').replace(/"/g, '\\"'); |
| } |
| |
| break; |
| case 'word': |
| newUrl = newUrl.replace(/("|'|\)|\\)/g, '\\$1'); |
| break; |
| } |
| |
| nestedNode.value = newUrl; |
| }); |
| } |
| break; |
| } |
| return node; |
| } |
| |
| function isWordAFunctionArgument(wordNode, functionNode) { |
| return functionNode |
| ? functionNode.nodes.some( |
| functionNodeChild => |
| functionNodeChild.sourceIndex === wordNode.sourceIndex |
| ) |
| : false; |
| } |
| |
| function localizeAnimationShorthandDeclValues(decl, context) { |
| const validIdent = /^-?[_a-z][_a-z0-9-]*$/i; |
| |
| /* |
| The spec defines some keywords that you can use to describe properties such as the timing |
| function. These are still valid animation names, so as long as there is a property that accepts |
| a keyword, it is given priority. Only when all the properties that can take a keyword are |
| exhausted can the animation name be set to the keyword. I.e. |
| |
| animation: infinite infinite; |
| |
| The animation will repeat an infinite number of times from the first argument, and will have an |
| animation name of infinite from the second. |
| */ |
| const animationKeywords = { |
| $alternate: 1, |
| '$alternate-reverse': 1, |
| $backwards: 1, |
| $both: 1, |
| $ease: 1, |
| '$ease-in': 1, |
| '$ease-in-out': 1, |
| '$ease-out': 1, |
| $forwards: 1, |
| $infinite: 1, |
| $linear: 1, |
| $none: Infinity, // No matter how many times you write none, it will never be an animation name |
| $normal: 1, |
| $paused: 1, |
| $reverse: 1, |
| $running: 1, |
| '$step-end': 1, |
| '$step-start': 1, |
| $initial: Infinity, |
| $inherit: Infinity, |
| $unset: Infinity, |
| }; |
| |
| const didParseAnimationName = false; |
| let parsedAnimationKeywords = {}; |
| let stepsFunctionNode = null; |
| const valueNodes = valueParser(decl.value).walk(node => { |
| /* If div-token appeared (represents as comma ','), a possibility of an animation-keywords should be reflesh. */ |
| if (node.type === 'div') { |
| parsedAnimationKeywords = {}; |
| } |
| if (node.type === 'function' && node.value.toLowerCase() === 'steps') { |
| stepsFunctionNode = node; |
| } |
| const value = |
| node.type === 'word' && !isWordAFunctionArgument(node, stepsFunctionNode) |
| ? node.value.toLowerCase() |
| : null; |
| |
| let shouldParseAnimationName = false; |
| |
| if (!didParseAnimationName && value && validIdent.test(value)) { |
| if ('$' + value in animationKeywords) { |
| parsedAnimationKeywords['$' + value] = |
| '$' + value in parsedAnimationKeywords |
| ? parsedAnimationKeywords['$' + value] + 1 |
| : 0; |
| |
| shouldParseAnimationName = |
| parsedAnimationKeywords['$' + value] >= |
| animationKeywords['$' + value]; |
| } else { |
| shouldParseAnimationName = true; |
| } |
| } |
| |
| const subContext = { |
| options: context.options, |
| global: context.global, |
| localizeNextItem: shouldParseAnimationName && !context.global, |
| localAliasMap: context.localAliasMap, |
| }; |
| return localizeDeclNode(node, subContext); |
| }); |
| |
| decl.value = valueNodes.toString(); |
| } |
| |
| function localizeDeclValues(localize, decl, context) { |
| const valueNodes = valueParser(decl.value); |
| valueNodes.walk((node, index, nodes) => { |
| const subContext = { |
| options: context.options, |
| global: context.global, |
| localizeNextItem: localize && !context.global, |
| localAliasMap: context.localAliasMap, |
| }; |
| nodes[index] = localizeDeclNode(node, subContext); |
| }); |
| decl.value = valueNodes.toString(); |
| } |
| |
| function localizeDecl(decl, context) { |
| const isAnimation = /animation$/i.test(decl.prop); |
| |
| if (isAnimation) { |
| return localizeAnimationShorthandDeclValues(decl, context); |
| } |
| |
| const isAnimationName = /animation(-name)?$/i.test(decl.prop); |
| |
| if (isAnimationName) { |
| return localizeDeclValues(true, decl, context); |
| } |
| |
| const hasUrl = /url\(/i.test(decl.value); |
| |
| if (hasUrl) { |
| return localizeDeclValues(false, decl, context); |
| } |
| } |
| |
| module.exports = postcss.plugin('postcss-modules-local-by-default', function( |
| options |
| ) { |
| if (typeof options !== 'object') { |
| options = {}; // If options is undefined or not an object the plugin fails |
| } |
| |
| if (options && options.mode) { |
| if ( |
| options.mode !== 'global' && |
| options.mode !== 'local' && |
| options.mode !== 'pure' |
| ) { |
| throw new Error( |
| 'options.mode must be either "global", "local" or "pure" (default "local")' |
| ); |
| } |
| } |
| |
| const pureMode = options && options.mode === 'pure'; |
| const globalMode = options && options.mode === 'global'; |
| |
| return function(css) { |
| const { icssImports } = extractICSS(css, false); |
| const localAliasMap = getImportLocalAliases(icssImports); |
| |
| css.walkAtRules(function(atrule) { |
| if (/keyframes$/i.test(atrule.name)) { |
| const globalMatch = /^\s*:global\s*\((.+)\)\s*$/.exec(atrule.params); |
| const localMatch = /^\s*:local\s*\((.+)\)\s*$/.exec(atrule.params); |
| let globalKeyframes = globalMode; |
| if (globalMatch) { |
| if (pureMode) { |
| throw atrule.error( |
| '@keyframes :global(...) is not allowed in pure mode' |
| ); |
| } |
| atrule.params = globalMatch[1]; |
| globalKeyframes = true; |
| } else if (localMatch) { |
| atrule.params = localMatch[0]; |
| globalKeyframes = false; |
| } else if (!globalMode) { |
| if (atrule.params && !localAliasMap.has(atrule.params)) |
| atrule.params = ':local(' + atrule.params + ')'; |
| } |
| atrule.walkDecls(function(decl) { |
| localizeDecl(decl, { |
| localAliasMap, |
| options: options, |
| global: globalKeyframes, |
| }); |
| }); |
| } else if (atrule.nodes) { |
| atrule.nodes.forEach(function(decl) { |
| if (decl.type === 'decl') { |
| localizeDecl(decl, { |
| localAliasMap, |
| options: options, |
| global: globalMode, |
| }); |
| } |
| }); |
| } |
| }); |
| |
| css.walkRules(function(rule) { |
| if ( |
| rule.parent && |
| rule.parent.type === 'atrule' && |
| /keyframes$/i.test(rule.parent.name) |
| ) { |
| // ignore keyframe rules |
| return; |
| } |
| |
| if ( |
| rule.nodes && |
| rule.selector.slice(0, 2) === '--' && |
| rule.selector.slice(-1) === ':' |
| ) { |
| // ignore custom property set |
| return; |
| } |
| |
| const context = localizeNode(rule, options.mode, localAliasMap); |
| |
| context.options = options; |
| context.localAliasMap = localAliasMap; |
| |
| if (pureMode && context.hasPureGlobals) { |
| throw rule.error( |
| 'Selector "' + |
| rule.selector + |
| '" is not pure ' + |
| '(pure selectors must contain at least one local class or id)' |
| ); |
| } |
| |
| rule.selector = context.selector; |
| |
| // Less-syntax mixins parse as rules with no nodes |
| if (rule.nodes) { |
| rule.nodes.forEach(decl => localizeDecl(decl, context)); |
| } |
| }); |
| }; |
| }); |