550 lines
15 KiB
JavaScript
550 lines
15 KiB
JavaScript
|
'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 isImportExportPseudo = node =>
|
||
|
node.value === ':import' || node.value === ':export';
|
||
|
|
||
|
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);
|
||
|
const isImportExport = isImportExportPseudo(node);
|
||
|
|
||
|
if (isImportExport) {
|
||
|
context.hasLocals = true;
|
||
|
// :local(.foo)
|
||
|
} else 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));
|
||
|
}
|
||
|
});
|
||
|
};
|
||
|
});
|