'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)); } }); }; });