index.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. "use strict";
  2. const selectorParser = require("postcss-selector-parser");
  3. const valueParser = require("postcss-value-parser");
  4. const { extractICSS } = require("icss-utils");
  5. const IGNORE_FILE_MARKER = "cssmodules-pure-no-check";
  6. const IGNORE_NEXT_LINE_MARKER = "cssmodules-pure-ignore";
  7. const isSpacing = (node) => node.type === "combinator" && node.value === " ";
  8. const isPureCheckDisabled = (root) => {
  9. for (const node of root.nodes) {
  10. if (node.type !== "comment") {
  11. return false;
  12. }
  13. if (node.text.trim().startsWith(IGNORE_FILE_MARKER)) {
  14. return true;
  15. }
  16. }
  17. return false;
  18. };
  19. function getIgnoreComment(node) {
  20. if (!node.parent) {
  21. return;
  22. }
  23. const indexInParent = node.parent.index(node);
  24. for (let i = indexInParent - 1; i >= 0; i--) {
  25. const prevNode = node.parent.nodes[i];
  26. if (prevNode.type === "comment") {
  27. if (prevNode.text.trimStart().startsWith(IGNORE_NEXT_LINE_MARKER)) {
  28. return prevNode;
  29. }
  30. } else {
  31. break;
  32. }
  33. }
  34. }
  35. function normalizeNodeArray(nodes) {
  36. const array = [];
  37. nodes.forEach((x) => {
  38. if (Array.isArray(x)) {
  39. normalizeNodeArray(x).forEach((item) => {
  40. array.push(item);
  41. });
  42. } else if (x) {
  43. array.push(x);
  44. }
  45. });
  46. if (array.length > 0 && isSpacing(array[array.length - 1])) {
  47. array.pop();
  48. }
  49. return array;
  50. }
  51. const isPureSelectorSymbol = Symbol("is-pure-selector");
  52. function localizeNode(rule, mode, localAliasMap) {
  53. const transform = (node, context) => {
  54. if (context.ignoreNextSpacing && !isSpacing(node)) {
  55. throw new Error("Missing whitespace after " + context.ignoreNextSpacing);
  56. }
  57. if (context.enforceNoSpacing && isSpacing(node)) {
  58. throw new Error("Missing whitespace before " + context.enforceNoSpacing);
  59. }
  60. let newNodes;
  61. switch (node.type) {
  62. case "root": {
  63. let resultingGlobal;
  64. context.hasPureGlobals = false;
  65. newNodes = node.nodes.map((n) => {
  66. const nContext = {
  67. global: context.global,
  68. lastWasSpacing: true,
  69. hasLocals: false,
  70. explicit: false,
  71. };
  72. n = transform(n, nContext);
  73. if (typeof resultingGlobal === "undefined") {
  74. resultingGlobal = nContext.global;
  75. } else if (resultingGlobal !== nContext.global) {
  76. throw new Error(
  77. 'Inconsistent rule global/local result in rule "' +
  78. node +
  79. '" (multiple selectors must result in the same mode for the rule)'
  80. );
  81. }
  82. if (!nContext.hasLocals) {
  83. context.hasPureGlobals = true;
  84. }
  85. return n;
  86. });
  87. context.global = resultingGlobal;
  88. node.nodes = normalizeNodeArray(newNodes);
  89. break;
  90. }
  91. case "selector": {
  92. newNodes = node.map((childNode) => transform(childNode, context));
  93. node = node.clone();
  94. node.nodes = normalizeNodeArray(newNodes);
  95. break;
  96. }
  97. case "combinator": {
  98. if (isSpacing(node)) {
  99. if (context.ignoreNextSpacing) {
  100. context.ignoreNextSpacing = false;
  101. context.lastWasSpacing = false;
  102. context.enforceNoSpacing = false;
  103. return null;
  104. }
  105. context.lastWasSpacing = true;
  106. return node;
  107. }
  108. break;
  109. }
  110. case "pseudo": {
  111. let childContext;
  112. const isNested = !!node.length;
  113. const isScoped = node.value === ":local" || node.value === ":global";
  114. const isImportExport =
  115. node.value === ":import" || node.value === ":export";
  116. if (isImportExport) {
  117. context.hasLocals = true;
  118. // :local(.foo)
  119. } else if (isNested) {
  120. if (isScoped) {
  121. if (node.nodes.length === 0) {
  122. throw new Error(`${node.value}() can't be empty`);
  123. }
  124. if (context.inside) {
  125. throw new Error(
  126. `A ${node.value} is not allowed inside of a ${context.inside}(...)`
  127. );
  128. }
  129. childContext = {
  130. global: node.value === ":global",
  131. inside: node.value,
  132. hasLocals: false,
  133. explicit: true,
  134. };
  135. newNodes = node
  136. .map((childNode) => transform(childNode, childContext))
  137. .reduce((acc, next) => acc.concat(next.nodes), []);
  138. if (newNodes.length) {
  139. const { before, after } = node.spaces;
  140. const first = newNodes[0];
  141. const last = newNodes[newNodes.length - 1];
  142. first.spaces = { before, after: first.spaces.after };
  143. last.spaces = { before: last.spaces.before, after };
  144. }
  145. node = newNodes;
  146. break;
  147. } else {
  148. childContext = {
  149. global: context.global,
  150. inside: context.inside,
  151. lastWasSpacing: true,
  152. hasLocals: false,
  153. explicit: context.explicit,
  154. };
  155. newNodes = node.map((childNode) => {
  156. const newContext = {
  157. ...childContext,
  158. enforceNoSpacing: false,
  159. };
  160. const result = transform(childNode, newContext);
  161. childContext.global = newContext.global;
  162. childContext.hasLocals = newContext.hasLocals;
  163. return result;
  164. });
  165. node = node.clone();
  166. node.nodes = normalizeNodeArray(newNodes);
  167. if (childContext.hasLocals) {
  168. context.hasLocals = true;
  169. }
  170. }
  171. break;
  172. //:local .foo .bar
  173. } else if (isScoped) {
  174. if (context.inside) {
  175. throw new Error(
  176. `A ${node.value} is not allowed inside of a ${context.inside}(...)`
  177. );
  178. }
  179. const addBackSpacing = !!node.spaces.before;
  180. context.ignoreNextSpacing = context.lastWasSpacing
  181. ? node.value
  182. : false;
  183. context.enforceNoSpacing = context.lastWasSpacing
  184. ? false
  185. : node.value;
  186. context.global = node.value === ":global";
  187. context.explicit = true;
  188. // because this node has spacing that is lost when we remove it
  189. // we make up for it by adding an extra combinator in since adding
  190. // spacing on the parent selector doesn't work
  191. return addBackSpacing
  192. ? selectorParser.combinator({ value: " " })
  193. : null;
  194. }
  195. break;
  196. }
  197. case "id":
  198. case "class": {
  199. if (!node.value) {
  200. throw new Error("Invalid class or id selector syntax");
  201. }
  202. if (context.global) {
  203. break;
  204. }
  205. const isImportedValue = localAliasMap.has(node.value);
  206. const isImportedWithExplicitScope = isImportedValue && context.explicit;
  207. if (!isImportedValue || isImportedWithExplicitScope) {
  208. const innerNode = node.clone();
  209. innerNode.spaces = { before: "", after: "" };
  210. node = selectorParser.pseudo({
  211. value: ":local",
  212. nodes: [innerNode],
  213. spaces: node.spaces,
  214. });
  215. context.hasLocals = true;
  216. }
  217. break;
  218. }
  219. case "nesting": {
  220. if (node.value === "&") {
  221. context.hasLocals = rule.parent[isPureSelectorSymbol];
  222. }
  223. }
  224. }
  225. context.lastWasSpacing = false;
  226. context.ignoreNextSpacing = false;
  227. context.enforceNoSpacing = false;
  228. return node;
  229. };
  230. const rootContext = {
  231. global: mode === "global",
  232. hasPureGlobals: false,
  233. };
  234. rootContext.selector = selectorParser((root) => {
  235. transform(root, rootContext);
  236. }).processSync(rule, { updateSelector: false, lossless: true });
  237. return rootContext;
  238. }
  239. function localizeDeclNode(node, context) {
  240. switch (node.type) {
  241. case "word":
  242. if (context.localizeNextItem) {
  243. if (!context.localAliasMap.has(node.value)) {
  244. node.value = ":local(" + node.value + ")";
  245. context.localizeNextItem = false;
  246. }
  247. }
  248. break;
  249. case "function":
  250. if (
  251. context.options &&
  252. context.options.rewriteUrl &&
  253. node.value.toLowerCase() === "url"
  254. ) {
  255. node.nodes.map((nestedNode) => {
  256. if (nestedNode.type !== "string" && nestedNode.type !== "word") {
  257. return;
  258. }
  259. let newUrl = context.options.rewriteUrl(
  260. context.global,
  261. nestedNode.value
  262. );
  263. switch (nestedNode.type) {
  264. case "string":
  265. if (nestedNode.quote === "'") {
  266. newUrl = newUrl.replace(/(\\)/g, "\\$1").replace(/'/g, "\\'");
  267. }
  268. if (nestedNode.quote === '"') {
  269. newUrl = newUrl.replace(/(\\)/g, "\\$1").replace(/"/g, '\\"');
  270. }
  271. break;
  272. case "word":
  273. newUrl = newUrl.replace(/("|'|\)|\\)/g, "\\$1");
  274. break;
  275. }
  276. nestedNode.value = newUrl;
  277. });
  278. }
  279. break;
  280. }
  281. return node;
  282. }
  283. // `none` is special value, other is global values
  284. const specialKeywords = [
  285. "none",
  286. "inherit",
  287. "initial",
  288. "revert",
  289. "revert-layer",
  290. "unset",
  291. ];
  292. function localizeDeclarationValues(localize, declaration, context) {
  293. const valueNodes = valueParser(declaration.value);
  294. valueNodes.walk((node, index, nodes) => {
  295. if (
  296. node.type === "function" &&
  297. (node.value.toLowerCase() === "var" || node.value.toLowerCase() === "env")
  298. ) {
  299. return false;
  300. }
  301. if (
  302. node.type === "word" &&
  303. specialKeywords.includes(node.value.toLowerCase())
  304. ) {
  305. return;
  306. }
  307. const subContext = {
  308. options: context.options,
  309. global: context.global,
  310. localizeNextItem: localize && !context.global,
  311. localAliasMap: context.localAliasMap,
  312. };
  313. nodes[index] = localizeDeclNode(node, subContext);
  314. });
  315. declaration.value = valueNodes.toString();
  316. }
  317. // letter
  318. // An uppercase letter or a lowercase letter.
  319. //
  320. // ident-start code point
  321. // A letter, a non-ASCII code point, or U+005F LOW LINE (_).
  322. //
  323. // ident code point
  324. // An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-).
  325. // We don't validate `hex digits`, because we don't need it, it is work of linters.
  326. const validIdent =
  327. /^-?([a-z\u0080-\uFFFF_]|(\\[^\r\n\f])|-(?![0-9]))((\\[^\r\n\f])|[a-z\u0080-\uFFFF_0-9-])*$/i;
  328. /*
  329. The spec defines some keywords that you can use to describe properties such as the timing
  330. function. These are still valid animation names, so as long as there is a property that accepts
  331. a keyword, it is given priority. Only when all the properties that can take a keyword are
  332. exhausted can the animation name be set to the keyword. I.e.
  333. animation: infinite infinite;
  334. The animation will repeat an infinite number of times from the first argument, and will have an
  335. animation name of infinite from the second.
  336. */
  337. const animationKeywords = {
  338. // animation-direction
  339. $normal: 1,
  340. $reverse: 1,
  341. $alternate: 1,
  342. "$alternate-reverse": 1,
  343. // animation-fill-mode
  344. $forwards: 1,
  345. $backwards: 1,
  346. $both: 1,
  347. // animation-iteration-count
  348. $infinite: 1,
  349. // animation-play-state
  350. $paused: 1,
  351. $running: 1,
  352. // animation-timing-function
  353. $ease: 1,
  354. "$ease-in": 1,
  355. "$ease-out": 1,
  356. "$ease-in-out": 1,
  357. $linear: 1,
  358. "$step-end": 1,
  359. "$step-start": 1,
  360. // Special
  361. $none: Infinity, // No matter how many times you write none, it will never be an animation name
  362. // Global values
  363. $initial: Infinity,
  364. $inherit: Infinity,
  365. $unset: Infinity,
  366. $revert: Infinity,
  367. "$revert-layer": Infinity,
  368. };
  369. function localizeDeclaration(declaration, context) {
  370. const isAnimation = /animation(-name)?$/i.test(declaration.prop);
  371. if (isAnimation) {
  372. let parsedAnimationKeywords = {};
  373. const valueNodes = valueParser(declaration.value).walk((node) => {
  374. // If div-token appeared (represents as comma ','), a possibility of an animation-keywords should be reflesh.
  375. if (node.type === "div") {
  376. parsedAnimationKeywords = {};
  377. return;
  378. } else if (
  379. node.type === "function" &&
  380. node.value.toLowerCase() === "local" &&
  381. node.nodes.length === 1
  382. ) {
  383. node.type = "word";
  384. node.value = node.nodes[0].value;
  385. return localizeDeclNode(node, {
  386. options: context.options,
  387. global: context.global,
  388. localizeNextItem: true,
  389. localAliasMap: context.localAliasMap,
  390. });
  391. } else if (node.type === "function") {
  392. // replace `animation: global(example)` with `animation-name: example`
  393. if (node.value.toLowerCase() === "global" && node.nodes.length === 1) {
  394. node.type = "word";
  395. node.value = node.nodes[0].value;
  396. }
  397. // Do not handle nested functions
  398. return false;
  399. }
  400. // Ignore all except word
  401. else if (node.type !== "word") {
  402. return;
  403. }
  404. const value = node.type === "word" ? node.value.toLowerCase() : null;
  405. let shouldParseAnimationName = false;
  406. if (value && validIdent.test(value)) {
  407. if ("$" + value in animationKeywords) {
  408. parsedAnimationKeywords["$" + value] =
  409. "$" + value in parsedAnimationKeywords
  410. ? parsedAnimationKeywords["$" + value] + 1
  411. : 0;
  412. shouldParseAnimationName =
  413. parsedAnimationKeywords["$" + value] >=
  414. animationKeywords["$" + value];
  415. } else {
  416. shouldParseAnimationName = true;
  417. }
  418. }
  419. return localizeDeclNode(node, {
  420. options: context.options,
  421. global: context.global,
  422. localizeNextItem: shouldParseAnimationName && !context.global,
  423. localAliasMap: context.localAliasMap,
  424. });
  425. });
  426. declaration.value = valueNodes.toString();
  427. return;
  428. }
  429. if (/url\(/i.test(declaration.value)) {
  430. return localizeDeclarationValues(false, declaration, context);
  431. }
  432. }
  433. const isPureSelector = (context, rule) => {
  434. if (!rule.parent || rule.type === "root") {
  435. return !context.hasPureGlobals;
  436. }
  437. if (rule.type === "rule" && rule[isPureSelectorSymbol]) {
  438. return rule[isPureSelectorSymbol] || isPureSelector(context, rule.parent);
  439. }
  440. return !context.hasPureGlobals || isPureSelector(context, rule.parent);
  441. };
  442. const isNodeWithoutDeclarations = (rule) => {
  443. if (rule.nodes.length > 0) {
  444. return !rule.nodes.every(
  445. (item) =>
  446. item.type === "rule" ||
  447. (item.type === "atrule" && !isNodeWithoutDeclarations(item))
  448. );
  449. }
  450. return true;
  451. };
  452. module.exports = (options = {}) => {
  453. if (
  454. options &&
  455. options.mode &&
  456. options.mode !== "global" &&
  457. options.mode !== "local" &&
  458. options.mode !== "pure"
  459. ) {
  460. throw new Error(
  461. 'options.mode must be either "global", "local" or "pure" (default "local")'
  462. );
  463. }
  464. const pureMode = options && options.mode === "pure";
  465. const globalMode = options && options.mode === "global";
  466. return {
  467. postcssPlugin: "postcss-modules-local-by-default",
  468. prepare() {
  469. const localAliasMap = new Map();
  470. return {
  471. Once(root) {
  472. const { icssImports } = extractICSS(root, false);
  473. const enforcePureMode = pureMode && !isPureCheckDisabled(root);
  474. Object.keys(icssImports).forEach((key) => {
  475. Object.keys(icssImports[key]).forEach((prop) => {
  476. localAliasMap.set(prop, icssImports[key][prop]);
  477. });
  478. });
  479. root.walkAtRules((atRule) => {
  480. if (/keyframes$/i.test(atRule.name)) {
  481. const globalMatch = /^\s*:global\s*\((.+)\)\s*$/.exec(
  482. atRule.params
  483. );
  484. const localMatch = /^\s*:local\s*\((.+)\)\s*$/.exec(
  485. atRule.params
  486. );
  487. let globalKeyframes = globalMode;
  488. if (globalMatch) {
  489. if (enforcePureMode) {
  490. const ignoreComment = getIgnoreComment(atRule);
  491. if (!ignoreComment) {
  492. throw atRule.error(
  493. "@keyframes :global(...) is not allowed in pure mode"
  494. );
  495. } else {
  496. ignoreComment.remove();
  497. }
  498. }
  499. atRule.params = globalMatch[1];
  500. globalKeyframes = true;
  501. } else if (localMatch) {
  502. atRule.params = localMatch[0];
  503. globalKeyframes = false;
  504. } else if (
  505. atRule.params &&
  506. !globalMode &&
  507. !localAliasMap.has(atRule.params)
  508. ) {
  509. atRule.params = ":local(" + atRule.params + ")";
  510. }
  511. atRule.walkDecls((declaration) => {
  512. localizeDeclaration(declaration, {
  513. localAliasMap,
  514. options: options,
  515. global: globalKeyframes,
  516. });
  517. });
  518. } else if (/scope$/i.test(atRule.name)) {
  519. if (atRule.params) {
  520. const ignoreComment = pureMode
  521. ? getIgnoreComment(atRule)
  522. : undefined;
  523. if (ignoreComment) {
  524. ignoreComment.remove();
  525. }
  526. atRule.params = atRule.params
  527. .split("to")
  528. .map((item) => {
  529. const selector = item.trim().slice(1, -1).trim();
  530. const context = localizeNode(
  531. selector,
  532. options.mode,
  533. localAliasMap
  534. );
  535. context.options = options;
  536. context.localAliasMap = localAliasMap;
  537. if (
  538. enforcePureMode &&
  539. context.hasPureGlobals &&
  540. !ignoreComment
  541. ) {
  542. throw atRule.error(
  543. 'Selector in at-rule"' +
  544. selector +
  545. '" is not pure ' +
  546. "(pure selectors must contain at least one local class or id)"
  547. );
  548. }
  549. return `(${context.selector})`;
  550. })
  551. .join(" to ");
  552. }
  553. atRule.nodes.forEach((declaration) => {
  554. if (declaration.type === "decl") {
  555. localizeDeclaration(declaration, {
  556. localAliasMap,
  557. options: options,
  558. global: globalMode,
  559. });
  560. }
  561. });
  562. } else if (atRule.nodes) {
  563. atRule.nodes.forEach((declaration) => {
  564. if (declaration.type === "decl") {
  565. localizeDeclaration(declaration, {
  566. localAliasMap,
  567. options: options,
  568. global: globalMode,
  569. });
  570. }
  571. });
  572. }
  573. });
  574. root.walkRules((rule) => {
  575. if (
  576. rule.parent &&
  577. rule.parent.type === "atrule" &&
  578. /keyframes$/i.test(rule.parent.name)
  579. ) {
  580. // ignore keyframe rules
  581. return;
  582. }
  583. const context = localizeNode(rule, options.mode, localAliasMap);
  584. context.options = options;
  585. context.localAliasMap = localAliasMap;
  586. const ignoreComment = enforcePureMode
  587. ? getIgnoreComment(rule)
  588. : undefined;
  589. const isNotPure = enforcePureMode && !isPureSelector(context, rule);
  590. if (
  591. isNotPure &&
  592. isNodeWithoutDeclarations(rule) &&
  593. !ignoreComment
  594. ) {
  595. throw rule.error(
  596. 'Selector "' +
  597. rule.selector +
  598. '" is not pure ' +
  599. "(pure selectors must contain at least one local class or id)"
  600. );
  601. } else if (ignoreComment) {
  602. ignoreComment.remove();
  603. }
  604. if (pureMode) {
  605. rule[isPureSelectorSymbol] = !isNotPure;
  606. }
  607. rule.selector = context.selector;
  608. // Less-syntax mixins parse as rules with no nodes
  609. if (rule.nodes) {
  610. rule.nodes.forEach((declaration) =>
  611. localizeDeclaration(declaration, context)
  612. );
  613. }
  614. });
  615. },
  616. };
  617. },
  618. };
  619. };
  620. module.exports.postcss = true;