index.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. "use strict";
  2. const selectorParser = require("postcss-selector-parser");
  3. const hasOwnProperty = Object.prototype.hasOwnProperty;
  4. function getSingleLocalNamesForComposes(root) {
  5. return root.nodes.map((node) => {
  6. if (node.type !== "selector" || node.nodes.length !== 1) {
  7. throw new Error(
  8. `composition is only allowed when selector is single :local class name not in "${root}"`
  9. );
  10. }
  11. node = node.nodes[0];
  12. if (
  13. node.type !== "pseudo" ||
  14. node.value !== ":local" ||
  15. node.nodes.length !== 1
  16. ) {
  17. throw new Error(
  18. 'composition is only allowed when selector is single :local class name not in "' +
  19. root +
  20. '", "' +
  21. node +
  22. '" is weird'
  23. );
  24. }
  25. node = node.first;
  26. if (node.type !== "selector" || node.length !== 1) {
  27. throw new Error(
  28. 'composition is only allowed when selector is single :local class name not in "' +
  29. root +
  30. '", "' +
  31. node +
  32. '" is weird'
  33. );
  34. }
  35. node = node.first;
  36. if (node.type !== "class") {
  37. // 'id' is not possible, because you can't compose ids
  38. throw new Error(
  39. 'composition is only allowed when selector is single :local class name not in "' +
  40. root +
  41. '", "' +
  42. node +
  43. '" is weird'
  44. );
  45. }
  46. return node.value;
  47. });
  48. }
  49. const whitespace = "[\\x20\\t\\r\\n\\f]";
  50. const unescapeRegExp = new RegExp(
  51. "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)",
  52. "ig"
  53. );
  54. function unescape(str) {
  55. return str.replace(unescapeRegExp, (_, escaped, escapedWhitespace) => {
  56. const high = "0x" + escaped - 0x10000;
  57. // NaN means non-codepoint
  58. // Workaround erroneous numeric interpretation of +"0x"
  59. return high !== high || escapedWhitespace
  60. ? escaped
  61. : high < 0
  62. ? // BMP codepoint
  63. String.fromCharCode(high + 0x10000)
  64. : // Supplemental Plane codepoint (surrogate pair)
  65. String.fromCharCode((high >> 10) | 0xd800, (high & 0x3ff) | 0xdc00);
  66. });
  67. }
  68. const plugin = (options = {}) => {
  69. const generateScopedName =
  70. (options && options.generateScopedName) || plugin.generateScopedName;
  71. const generateExportEntry =
  72. (options && options.generateExportEntry) || plugin.generateExportEntry;
  73. const exportGlobals = options && options.exportGlobals;
  74. return {
  75. postcssPlugin: "postcss-modules-scope",
  76. Once(root, { rule }) {
  77. const exports = Object.create(null);
  78. function exportScopedName(name, rawName) {
  79. const scopedName = generateScopedName(
  80. rawName ? rawName : name,
  81. root.source.input.from,
  82. root.source.input.css
  83. );
  84. const exportEntry = generateExportEntry(
  85. rawName ? rawName : name,
  86. scopedName,
  87. root.source.input.from,
  88. root.source.input.css
  89. );
  90. const { key, value } = exportEntry;
  91. exports[key] = exports[key] || [];
  92. if (exports[key].indexOf(value) < 0) {
  93. exports[key].push(value);
  94. }
  95. return scopedName;
  96. }
  97. function localizeNode(node) {
  98. switch (node.type) {
  99. case "selector":
  100. node.nodes = node.map(localizeNode);
  101. return node;
  102. case "class":
  103. return selectorParser.className({
  104. value: exportScopedName(
  105. node.value,
  106. node.raws && node.raws.value ? node.raws.value : null
  107. ),
  108. });
  109. case "id": {
  110. return selectorParser.id({
  111. value: exportScopedName(
  112. node.value,
  113. node.raws && node.raws.value ? node.raws.value : null
  114. ),
  115. });
  116. }
  117. }
  118. throw new Error(
  119. `${node.type} ("${node}") is not allowed in a :local block`
  120. );
  121. }
  122. function traverseNode(node) {
  123. switch (node.type) {
  124. case "pseudo":
  125. if (node.value === ":local") {
  126. if (node.nodes.length !== 1) {
  127. throw new Error('Unexpected comma (",") in :local block');
  128. }
  129. const selector = localizeNode(node.first, node.spaces);
  130. // move the spaces that were around the psuedo selector to the first
  131. // non-container node
  132. selector.first.spaces = node.spaces;
  133. const nextNode = node.next();
  134. if (
  135. nextNode &&
  136. nextNode.type === "combinator" &&
  137. nextNode.value === " " &&
  138. /\\[A-F0-9]{1,6}$/.test(selector.last.value)
  139. ) {
  140. selector.last.spaces.after = " ";
  141. }
  142. node.replaceWith(selector);
  143. return;
  144. }
  145. /* falls through */
  146. case "root":
  147. case "selector": {
  148. node.each(traverseNode);
  149. break;
  150. }
  151. case "id":
  152. case "class":
  153. if (exportGlobals) {
  154. exports[node.value] = [node.value];
  155. }
  156. break;
  157. }
  158. return node;
  159. }
  160. // Find any :import and remember imported names
  161. const importedNames = {};
  162. root.walkRules(/^:import\(.+\)$/, (rule) => {
  163. rule.walkDecls((decl) => {
  164. importedNames[decl.prop] = true;
  165. });
  166. });
  167. // Find any :local selectors
  168. root.walkRules((rule) => {
  169. let parsedSelector = selectorParser().astSync(rule);
  170. rule.selector = traverseNode(parsedSelector.clone()).toString();
  171. rule.walkDecls(/composes|compose-with/i, (decl) => {
  172. const localNames = getSingleLocalNamesForComposes(parsedSelector);
  173. const classes = decl.value.split(/\s+/);
  174. classes.forEach((className) => {
  175. const global = /^global\(([^)]+)\)$/.exec(className);
  176. if (global) {
  177. localNames.forEach((exportedName) => {
  178. exports[exportedName].push(global[1]);
  179. });
  180. } else if (hasOwnProperty.call(importedNames, className)) {
  181. localNames.forEach((exportedName) => {
  182. exports[exportedName].push(className);
  183. });
  184. } else if (hasOwnProperty.call(exports, className)) {
  185. localNames.forEach((exportedName) => {
  186. exports[className].forEach((item) => {
  187. exports[exportedName].push(item);
  188. });
  189. });
  190. } else {
  191. throw decl.error(
  192. `referenced class name "${className}" in ${decl.prop} not found`
  193. );
  194. }
  195. });
  196. decl.remove();
  197. });
  198. // Find any :local values
  199. rule.walkDecls((decl) => {
  200. if (!/:local\s*\((.+?)\)/.test(decl.value)) {
  201. return;
  202. }
  203. let tokens = decl.value.split(/(,|'[^']*'|"[^"]*")/);
  204. tokens = tokens.map((token, idx) => {
  205. if (idx === 0 || tokens[idx - 1] === ",") {
  206. let result = token;
  207. const localMatch = /:local\s*\((.+?)\)/.exec(token);
  208. if (localMatch) {
  209. const input = localMatch.input;
  210. const matchPattern = localMatch[0];
  211. const matchVal = localMatch[1];
  212. const newVal = exportScopedName(matchVal);
  213. result = input.replace(matchPattern, newVal);
  214. } else {
  215. return token;
  216. }
  217. return result;
  218. } else {
  219. return token;
  220. }
  221. });
  222. decl.value = tokens.join("");
  223. });
  224. });
  225. // Find any :local keyframes
  226. root.walkAtRules(/keyframes$/i, (atRule) => {
  227. const localMatch = /^\s*:local\s*\((.+?)\)\s*$/.exec(atRule.params);
  228. if (!localMatch) {
  229. return;
  230. }
  231. atRule.params = exportScopedName(localMatch[1]);
  232. });
  233. // If we found any :locals, insert an :export rule
  234. const exportedNames = Object.keys(exports);
  235. if (exportedNames.length > 0) {
  236. const exportRule = rule({ selector: ":export" });
  237. exportedNames.forEach((exportedName) =>
  238. exportRule.append({
  239. prop: exportedName,
  240. value: exports[exportedName].join(" "),
  241. raws: { before: "\n " },
  242. })
  243. );
  244. root.append(exportRule);
  245. }
  246. },
  247. };
  248. };
  249. plugin.postcss = true;
  250. plugin.generateScopedName = function (name, path) {
  251. const sanitisedPath = path
  252. .replace(/\.[^./\\]+$/, "")
  253. .replace(/[\W_]+/g, "_")
  254. .replace(/^_|_$/g, "");
  255. return `_${sanitisedPath}__${name}`.trim();
  256. };
  257. plugin.generateExportEntry = function (name, scopedName) {
  258. return {
  259. key: unescape(name),
  260. value: unescape(scopedName),
  261. };
  262. };
  263. module.exports = plugin;