findMediaFeatureNames.mjs 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import { TokenType, isToken, stringify, tokenize } from '@csstools/css-tokenizer';
  2. import {
  3. isGeneralEnclosed,
  4. isMediaFeature,
  5. isMediaQueryInvalid,
  6. parseFromTokens,
  7. } from '@csstools/media-query-list-parser';
  8. import {
  9. isSimpleBlockNode,
  10. isTokenNode,
  11. parseCommaSeparatedListOfComponentValues,
  12. } from '@csstools/css-parser-algorithms';
  13. /** @typedef {Array<import('@csstools/media-query-list-parser').MediaQuery>} MediaQueryList */
  14. /** @typedef {import('@csstools/css-tokenizer').TokenIdent} TokenIdent */
  15. /** @typedef {{ stringify: () => string }} MediaQuerySerializer */
  16. const rangeFeatureOperator = /[<>=]/;
  17. /**
  18. * Search a CSS string for Media Feature names.
  19. * For every found name, invoke the callback, passing the token
  20. * as an argument.
  21. *
  22. * Found tokens are mutable and modifications made to them will be reflected in the output.
  23. *
  24. * This function supports some non-standard syntaxes like SCSS variables and interpolation.
  25. *
  26. * @param {string} mediaQueryParams
  27. * @param {(mediaFeatureName: TokenIdent) => void} callback
  28. *
  29. * @returns {MediaQuerySerializer}
  30. */
  31. export default function findMediaFeatureNames(mediaQueryParams, callback) {
  32. const tokens = tokenize({ css: mediaQueryParams });
  33. const list = parseCommaSeparatedListOfComponentValues(tokens);
  34. const mediaQueryConditions = list.flatMap((listItem) => {
  35. return listItem.flatMap((componentValue) => {
  36. if (
  37. !isSimpleBlockNode(componentValue) ||
  38. componentValue.startToken[0] !== TokenType.OpenParen
  39. ) {
  40. return [];
  41. }
  42. const blockTokens = componentValue.tokens();
  43. const mediaQueryList = parseFromTokens(blockTokens, {
  44. preserveInvalidMediaQueries: true,
  45. });
  46. return mediaQueryList.filter((mediaQuery) => {
  47. return !isMediaQueryInvalid(mediaQuery);
  48. });
  49. });
  50. });
  51. mediaQueryConditions.forEach((mediaQuery) => {
  52. mediaQuery.walk(({ node }) => {
  53. if (isMediaFeature(node)) {
  54. const token = node.getNameToken();
  55. if (token[0] !== TokenType.Ident) return;
  56. callback(token);
  57. }
  58. if (isGeneralEnclosed(node)) {
  59. topLevelTokenNodes(node).forEach((token, i, topLevelTokens) => {
  60. if (token[0] !== TokenType.Ident) {
  61. return;
  62. }
  63. const nextToken = topLevelTokens[i + 1];
  64. const prevToken = topLevelTokens[i - 1];
  65. if (
  66. // Media Feature
  67. (!prevToken && nextToken && nextToken[0] === TokenType.Colon) ||
  68. // Range Feature
  69. (nextToken &&
  70. nextToken[0] === TokenType.Delim &&
  71. rangeFeatureOperator.test(nextToken[4].value)) ||
  72. // Range Feature
  73. (prevToken &&
  74. prevToken[0] === TokenType.Delim &&
  75. rangeFeatureOperator.test(prevToken[4].value))
  76. ) {
  77. callback(token);
  78. }
  79. });
  80. }
  81. });
  82. });
  83. // Serializing takes time/resources and not all callers will use this.
  84. // By returning an object with a stringify method, we can avoid doing
  85. // this work when it's not needed.
  86. return {
  87. stringify() {
  88. return stringify(...tokens);
  89. },
  90. };
  91. }
  92. /** @param {import('@csstools/media-query-list-parser').GeneralEnclosed} node */
  93. function topLevelTokenNodes(node) {
  94. const components = node.value.value;
  95. if (isToken(components) || components.length === 0 || isToken(components[0])) {
  96. return [];
  97. }
  98. /** @type {Array<import('@csstools/css-tokenizer').CSSToken>} */
  99. const relevantTokens = [];
  100. // To consume the next token if it is a scss variable
  101. let lastWasDollarSign = false;
  102. components.forEach((component) => {
  103. // Only preserve top level tokens (idents, delims, ...)
  104. // Discard all blocks, functions, ...
  105. if (component && isTokenNode(component)) {
  106. if (component.value[0] === TokenType.Delim && component.value[4].value === '$') {
  107. lastWasDollarSign = true;
  108. return;
  109. }
  110. if (lastWasDollarSign) {
  111. lastWasDollarSign = false;
  112. return;
  113. }
  114. relevantTokens.push(component.value);
  115. }
  116. });
  117. return relevantTokens;
  118. }