index.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. 'use strict';
  2. const postcss = require('postcss');
  3. const selectorParser = require('postcss-selector-parser');
  4. const valueParser = require('postcss-value-parser');
  5. const { extractICSS } = require('icss-utils');
  6. const isSpacing = node => node.type === 'combinator' && node.value === ' ';
  7. function getImportLocalAliases(icssImports) {
  8. const localAliases = new Map();
  9. Object.keys(icssImports).forEach(key => {
  10. Object.keys(icssImports[key]).forEach(prop => {
  11. localAliases.set(prop, icssImports[key][prop]);
  12. });
  13. });
  14. return localAliases;
  15. }
  16. function maybeLocalizeValue(value, localAliasMap) {
  17. if (localAliasMap.has(value)) return value;
  18. }
  19. function normalizeNodeArray(nodes) {
  20. const array = [];
  21. nodes.forEach(function(x) {
  22. if (Array.isArray(x)) {
  23. normalizeNodeArray(x).forEach(function(item) {
  24. array.push(item);
  25. });
  26. } else if (x) {
  27. array.push(x);
  28. }
  29. });
  30. if (array.length > 0 && isSpacing(array[array.length - 1])) {
  31. array.pop();
  32. }
  33. return array;
  34. }
  35. function localizeNode(rule, mode, localAliasMap) {
  36. const isScopePseudo = node =>
  37. node.value === ':local' || node.value === ':global';
  38. const transform = (node, context) => {
  39. if (context.ignoreNextSpacing && !isSpacing(node)) {
  40. throw new Error('Missing whitespace after ' + context.ignoreNextSpacing);
  41. }
  42. if (context.enforceNoSpacing && isSpacing(node)) {
  43. throw new Error('Missing whitespace before ' + context.enforceNoSpacing);
  44. }
  45. let newNodes;
  46. switch (node.type) {
  47. case 'root': {
  48. let resultingGlobal;
  49. context.hasPureGlobals = false;
  50. newNodes = node.nodes.map(function(n) {
  51. const nContext = {
  52. global: context.global,
  53. lastWasSpacing: true,
  54. hasLocals: false,
  55. explicit: false,
  56. };
  57. n = transform(n, nContext);
  58. if (typeof resultingGlobal === 'undefined') {
  59. resultingGlobal = nContext.global;
  60. } else if (resultingGlobal !== nContext.global) {
  61. throw new Error(
  62. 'Inconsistent rule global/local result in rule "' +
  63. node +
  64. '" (multiple selectors must result in the same mode for the rule)'
  65. );
  66. }
  67. if (!nContext.hasLocals) {
  68. context.hasPureGlobals = true;
  69. }
  70. return n;
  71. });
  72. context.global = resultingGlobal;
  73. node.nodes = normalizeNodeArray(newNodes);
  74. break;
  75. }
  76. case 'selector': {
  77. newNodes = node.map(childNode => transform(childNode, context));
  78. node = node.clone();
  79. node.nodes = normalizeNodeArray(newNodes);
  80. break;
  81. }
  82. case 'combinator': {
  83. if (isSpacing(node)) {
  84. if (context.ignoreNextSpacing) {
  85. context.ignoreNextSpacing = false;
  86. context.lastWasSpacing = false;
  87. context.enforceNoSpacing = false;
  88. return null;
  89. }
  90. context.lastWasSpacing = true;
  91. return node;
  92. }
  93. break;
  94. }
  95. case 'pseudo': {
  96. let childContext;
  97. const isNested = !!node.length;
  98. const isScoped = isScopePseudo(node);
  99. // :local(.foo)
  100. if (isNested) {
  101. if (isScoped) {
  102. if (node.nodes.length === 0) {
  103. throw new Error(`${node.value}() can't be empty`);
  104. }
  105. if (context.inside) {
  106. throw new Error(
  107. `A ${node.value} is not allowed inside of a ${
  108. context.inside
  109. }(...)`
  110. );
  111. }
  112. childContext = {
  113. global: node.value === ':global',
  114. inside: node.value,
  115. hasLocals: false,
  116. explicit: true,
  117. };
  118. newNodes = node
  119. .map(childNode => transform(childNode, childContext))
  120. .reduce((acc, next) => acc.concat(next.nodes), []);
  121. if (newNodes.length) {
  122. const { before, after } = node.spaces;
  123. const first = newNodes[0];
  124. const last = newNodes[newNodes.length - 1];
  125. first.spaces = { before, after: first.spaces.after };
  126. last.spaces = { before: last.spaces.before, after };
  127. }
  128. node = newNodes;
  129. break;
  130. } else {
  131. childContext = {
  132. global: context.global,
  133. inside: context.inside,
  134. lastWasSpacing: true,
  135. hasLocals: false,
  136. explicit: context.explicit,
  137. };
  138. newNodes = node.map(childNode =>
  139. transform(childNode, childContext)
  140. );
  141. node = node.clone();
  142. node.nodes = normalizeNodeArray(newNodes);
  143. if (childContext.hasLocals) {
  144. context.hasLocals = true;
  145. }
  146. }
  147. break;
  148. //:local .foo .bar
  149. } else if (isScoped) {
  150. if (context.inside) {
  151. throw new Error(
  152. `A ${node.value} is not allowed inside of a ${
  153. context.inside
  154. }(...)`
  155. );
  156. }
  157. const addBackSpacing = !!node.spaces.before;
  158. context.ignoreNextSpacing = context.lastWasSpacing
  159. ? node.value
  160. : false;
  161. context.enforceNoSpacing = context.lastWasSpacing
  162. ? false
  163. : node.value;
  164. context.global = node.value === ':global';
  165. context.explicit = true;
  166. // because this node has spacing that is lost when we remove it
  167. // we make up for it by adding an extra combinator in since adding
  168. // spacing on the parent selector doesn't work
  169. return addBackSpacing
  170. ? selectorParser.combinator({ value: ' ' })
  171. : null;
  172. }
  173. break;
  174. }
  175. case 'id':
  176. case 'class': {
  177. if (!node.value) {
  178. throw new Error('Invalid class or id selector syntax');
  179. }
  180. if (context.global) {
  181. break;
  182. }
  183. const isImportedValue = localAliasMap.has(node.value);
  184. const isImportedWithExplicitScope = isImportedValue && context.explicit;
  185. if (!isImportedValue || isImportedWithExplicitScope) {
  186. const innerNode = node.clone();
  187. innerNode.spaces = { before: '', after: '' };
  188. node = selectorParser.pseudo({
  189. value: ':local',
  190. nodes: [innerNode],
  191. spaces: node.spaces,
  192. });
  193. context.hasLocals = true;
  194. }
  195. break;
  196. }
  197. }
  198. context.lastWasSpacing = false;
  199. context.ignoreNextSpacing = false;
  200. context.enforceNoSpacing = false;
  201. return node;
  202. };
  203. const rootContext = {
  204. global: mode === 'global',
  205. hasPureGlobals: false,
  206. };
  207. rootContext.selector = selectorParser(root => {
  208. transform(root, rootContext);
  209. }).processSync(rule, { updateSelector: false, lossless: true });
  210. return rootContext;
  211. }
  212. function localizeDeclNode(node, context) {
  213. switch (node.type) {
  214. case 'word':
  215. if (context.localizeNextItem) {
  216. if (!context.localAliasMap.has(node.value)) {
  217. node.value = ':local(' + node.value + ')';
  218. context.localizeNextItem = false;
  219. }
  220. }
  221. break;
  222. case 'function':
  223. if (
  224. context.options &&
  225. context.options.rewriteUrl &&
  226. node.value.toLowerCase() === 'url'
  227. ) {
  228. node.nodes.map(nestedNode => {
  229. if (nestedNode.type !== 'string' && nestedNode.type !== 'word') {
  230. return;
  231. }
  232. let newUrl = context.options.rewriteUrl(
  233. context.global,
  234. nestedNode.value
  235. );
  236. switch (nestedNode.type) {
  237. case 'string':
  238. if (nestedNode.quote === "'") {
  239. newUrl = newUrl.replace(/(\\)/g, '\\$1').replace(/'/g, "\\'");
  240. }
  241. if (nestedNode.quote === '"') {
  242. newUrl = newUrl.replace(/(\\)/g, '\\$1').replace(/"/g, '\\"');
  243. }
  244. break;
  245. case 'word':
  246. newUrl = newUrl.replace(/("|'|\)|\\)/g, '\\$1');
  247. break;
  248. }
  249. nestedNode.value = newUrl;
  250. });
  251. }
  252. break;
  253. }
  254. return node;
  255. }
  256. function isWordAFunctionArgument(wordNode, functionNode) {
  257. return functionNode
  258. ? functionNode.nodes.some(
  259. functionNodeChild =>
  260. functionNodeChild.sourceIndex === wordNode.sourceIndex
  261. )
  262. : false;
  263. }
  264. function localizeAnimationShorthandDeclValues(decl, context) {
  265. const validIdent = /^-?[_a-z][_a-z0-9-]*$/i;
  266. /*
  267. The spec defines some keywords that you can use to describe properties such as the timing
  268. function. These are still valid animation names, so as long as there is a property that accepts
  269. a keyword, it is given priority. Only when all the properties that can take a keyword are
  270. exhausted can the animation name be set to the keyword. I.e.
  271. animation: infinite infinite;
  272. The animation will repeat an infinite number of times from the first argument, and will have an
  273. animation name of infinite from the second.
  274. */
  275. const animationKeywords = {
  276. $alternate: 1,
  277. '$alternate-reverse': 1,
  278. $backwards: 1,
  279. $both: 1,
  280. $ease: 1,
  281. '$ease-in': 1,
  282. '$ease-in-out': 1,
  283. '$ease-out': 1,
  284. $forwards: 1,
  285. $infinite: 1,
  286. $linear: 1,
  287. $none: Infinity, // No matter how many times you write none, it will never be an animation name
  288. $normal: 1,
  289. $paused: 1,
  290. $reverse: 1,
  291. $running: 1,
  292. '$step-end': 1,
  293. '$step-start': 1,
  294. $initial: Infinity,
  295. $inherit: Infinity,
  296. $unset: Infinity,
  297. };
  298. const didParseAnimationName = false;
  299. let parsedAnimationKeywords = {};
  300. let stepsFunctionNode = null;
  301. const valueNodes = valueParser(decl.value).walk(node => {
  302. /* If div-token appeared (represents as comma ','), a possibility of an animation-keywords should be reflesh. */
  303. if (node.type === 'div') {
  304. parsedAnimationKeywords = {};
  305. }
  306. if (node.type === 'function' && node.value.toLowerCase() === 'steps') {
  307. stepsFunctionNode = node;
  308. }
  309. const value =
  310. node.type === 'word' && !isWordAFunctionArgument(node, stepsFunctionNode)
  311. ? node.value.toLowerCase()
  312. : null;
  313. let shouldParseAnimationName = false;
  314. if (!didParseAnimationName && value && validIdent.test(value)) {
  315. if ('$' + value in animationKeywords) {
  316. parsedAnimationKeywords['$' + value] =
  317. '$' + value in parsedAnimationKeywords
  318. ? parsedAnimationKeywords['$' + value] + 1
  319. : 0;
  320. shouldParseAnimationName =
  321. parsedAnimationKeywords['$' + value] >=
  322. animationKeywords['$' + value];
  323. } else {
  324. shouldParseAnimationName = true;
  325. }
  326. }
  327. const subContext = {
  328. options: context.options,
  329. global: context.global,
  330. localizeNextItem: shouldParseAnimationName && !context.global,
  331. localAliasMap: context.localAliasMap,
  332. };
  333. return localizeDeclNode(node, subContext);
  334. });
  335. decl.value = valueNodes.toString();
  336. }
  337. function localizeDeclValues(localize, decl, context) {
  338. const valueNodes = valueParser(decl.value);
  339. valueNodes.walk((node, index, nodes) => {
  340. const subContext = {
  341. options: context.options,
  342. global: context.global,
  343. localizeNextItem: localize && !context.global,
  344. localAliasMap: context.localAliasMap,
  345. };
  346. nodes[index] = localizeDeclNode(node, subContext);
  347. });
  348. decl.value = valueNodes.toString();
  349. }
  350. function localizeDecl(decl, context) {
  351. const isAnimation = /animation$/i.test(decl.prop);
  352. if (isAnimation) {
  353. return localizeAnimationShorthandDeclValues(decl, context);
  354. }
  355. const isAnimationName = /animation(-name)?$/i.test(decl.prop);
  356. if (isAnimationName) {
  357. return localizeDeclValues(true, decl, context);
  358. }
  359. const hasUrl = /url\(/i.test(decl.value);
  360. if (hasUrl) {
  361. return localizeDeclValues(false, decl, context);
  362. }
  363. }
  364. module.exports = postcss.plugin('postcss-modules-local-by-default', function(
  365. options
  366. ) {
  367. if (typeof options !== 'object') {
  368. options = {}; // If options is undefined or not an object the plugin fails
  369. }
  370. if (options && options.mode) {
  371. if (
  372. options.mode !== 'global' &&
  373. options.mode !== 'local' &&
  374. options.mode !== 'pure'
  375. ) {
  376. throw new Error(
  377. 'options.mode must be either "global", "local" or "pure" (default "local")'
  378. );
  379. }
  380. }
  381. const pureMode = options && options.mode === 'pure';
  382. const globalMode = options && options.mode === 'global';
  383. return function(css) {
  384. const { icssImports } = extractICSS(css, false);
  385. const localAliasMap = getImportLocalAliases(icssImports);
  386. css.walkAtRules(function(atrule) {
  387. if (/keyframes$/i.test(atrule.name)) {
  388. const globalMatch = /^\s*:global\s*\((.+)\)\s*$/.exec(atrule.params);
  389. const localMatch = /^\s*:local\s*\((.+)\)\s*$/.exec(atrule.params);
  390. let globalKeyframes = globalMode;
  391. if (globalMatch) {
  392. if (pureMode) {
  393. throw atrule.error(
  394. '@keyframes :global(...) is not allowed in pure mode'
  395. );
  396. }
  397. atrule.params = globalMatch[1];
  398. globalKeyframes = true;
  399. } else if (localMatch) {
  400. atrule.params = localMatch[0];
  401. globalKeyframes = false;
  402. } else if (!globalMode) {
  403. if (atrule.params && !localAliasMap.has(atrule.params))
  404. atrule.params = ':local(' + atrule.params + ')';
  405. }
  406. atrule.walkDecls(function(decl) {
  407. localizeDecl(decl, {
  408. localAliasMap,
  409. options: options,
  410. global: globalKeyframes,
  411. });
  412. });
  413. } else if (atrule.nodes) {
  414. atrule.nodes.forEach(function(decl) {
  415. if (decl.type === 'decl') {
  416. localizeDecl(decl, {
  417. localAliasMap,
  418. options: options,
  419. global: globalMode,
  420. });
  421. }
  422. });
  423. }
  424. });
  425. css.walkRules(function(rule) {
  426. if (
  427. rule.parent &&
  428. rule.parent.type === 'atrule' &&
  429. /keyframes$/i.test(rule.parent.name)
  430. ) {
  431. // ignore keyframe rules
  432. return;
  433. }
  434. if (
  435. rule.nodes &&
  436. rule.selector.slice(0, 2) === '--' &&
  437. rule.selector.slice(-1) === ':'
  438. ) {
  439. // ignore custom property set
  440. return;
  441. }
  442. const context = localizeNode(rule, options.mode, localAliasMap);
  443. context.options = options;
  444. context.localAliasMap = localAliasMap;
  445. if (pureMode && context.hasPureGlobals) {
  446. throw rule.error(
  447. 'Selector "' +
  448. rule.selector +
  449. '" is not pure ' +
  450. '(pure selectors must contain at least one local class or id)'
  451. );
  452. }
  453. rule.selector = context.selector;
  454. // Less-syntax mixins parse as rules with no nodes
  455. if (rule.nodes) {
  456. rule.nodes.forEach(decl => localizeDecl(decl, context));
  457. }
  458. });
  459. };
  460. });