less 4.8.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,14 @@
1
1
  import Dimension from '../tree/dimension.js';
2
2
 
3
- const MathHelper = (fn, unit, n) => {
3
+ const MathHelper = (fn, unit, cssEvaluable, n) => {
4
4
  if (!(n instanceof Dimension)) {
5
+ // A runtime CSS value such as var()/env() stays an unevaluated call and
6
+ // cannot be resolved to a number at compile time. Only functions with a
7
+ // CSS equivalent may be left for the browser; the rest (percentage, ceil,
8
+ // floor, round) have no CSS form and keep erroring on such input.
9
+ if (cssEvaluable && n && n.type === 'Call') {
10
+ return undefined;
11
+ }
5
12
  throw { type: 'Argument', message: 'argument must be a number' };
6
13
  }
7
14
  if (unit === null) {
@@ -1,29 +1,30 @@
1
1
  import mathHelper from './math-helper.js';
2
2
 
3
3
  const mathFunctions = {
4
- // name, unit
5
- ceil: null,
6
- floor: null,
7
- sqrt: null,
8
- abs: null,
9
- tan: '',
10
- sin: '',
11
- cos: '',
12
- atan: 'rad',
13
- asin: 'rad',
14
- acos: 'rad'
4
+ // name: [unit, has a CSS equivalent that the browser can evaluate]
5
+ ceil: [null, false],
6
+ floor: [null, false],
7
+ sqrt: [null, true],
8
+ abs: [null, true],
9
+ tan: ['', true],
10
+ sin: ['', true],
11
+ cos: ['', true],
12
+ atan: ['rad', true],
13
+ asin: ['rad', true],
14
+ acos: ['rad', true]
15
15
  };
16
16
 
17
17
  for (const f in mathFunctions) {
18
18
  // eslint-disable-next-line no-prototype-builtins
19
19
  if (mathFunctions.hasOwnProperty(f)) {
20
- mathFunctions[f] = mathHelper.bind(null, Math[f], mathFunctions[f]);
20
+ const [unit, cssEvaluable] = mathFunctions[f];
21
+ mathFunctions[f] = mathHelper.bind(null, Math[f], unit, cssEvaluable);
21
22
  }
22
23
  }
23
24
 
24
25
  mathFunctions.round = (n, f) => {
25
26
  const fraction = typeof f === 'undefined' ? 0 : f.value;
26
- return mathHelper(num => num.toFixed(fraction), null, n);
27
+ return mathHelper(num => num.toFixed(fraction), null, false, n);
27
28
  };
28
29
 
29
30
  export default mathFunctions;
@@ -88,7 +88,7 @@ export default {
88
88
  return new Dimension(Math.pow(x.value, y.value), x.unit);
89
89
  },
90
90
  percentage: function (n) {
91
- const result = mathHelper(num => num * 100, '%', n);
91
+ const result = mathHelper(num => num * 100, '%', false, n);
92
92
 
93
93
  return result;
94
94
  }
@@ -7,9 +7,12 @@ export default {
7
7
  return new Quoted('"', str instanceof JavaScript ? str.evaluated : str.value, true);
8
8
  },
9
9
  escape: function (str) {
10
- return new Anonymous(
11
- encodeURI(str.value).replace(/=/g, '%3D').replace(/:/g, '%3A').replace(/#/g, '%23').replace(/;/g, '%3B')
12
- .replace(/\(/g, '%28').replace(/\)/g, '%29'));
10
+ const escapedValue = encodeURI(str.value).replace(/=/g, '%3D').replace(/:/g, '%3A').replace(/#/g, '%23').replace(/;/g, '%3B')
11
+ .replace(/\(/g, '%28').replace(/\)/g, '%29');
12
+ const escaped = new Anonymous(escapedValue);
13
+ // Percent escapes are literal CSS, not Less source.
14
+ escaped._preventReparse = escapedValue.includes('%');
15
+ return escaped;
13
16
  },
14
17
  replace: function (string, pattern, replacement, flags) {
15
18
  let result = string.value;
@@ -0,0 +1,26 @@
1
+ // @ts-check
2
+ /**
3
+ * Shared source-of-truth for the `[...]` lookup grammar.
4
+ *
5
+ * The parser consumes lookups structurally (`parsers.mixin.ruleLookups()`), but
6
+ * `Quoted` resolves interpolation by string replacement at eval time and so needs
7
+ * an equivalent regular expression. Keeping the pattern in one place is what stops
8
+ * the two from drifting apart — the drift is precisely why `@{map[key]}` used to be
9
+ * emitted verbatim instead of being substituted.
10
+ *
11
+ * A key mirrors `parsers.entities.lookupValue`: an optional `@`/`@@`/`$`/`$$`
12
+ * sigil followed by identifier characters. It may be empty (`@map[]` resolves to
13
+ * the last declaration). Critically the key pattern contains no brackets, so a
14
+ * lookup key can never itself be a lookup — `@{a[@b[c]]}` is not grammatical.
15
+ * A non-nesting regex is therefore exactly equivalent to the parsed grammar here
16
+ * rather than an approximation of it.
17
+ */
18
+
19
+ /** A single lookup key, e.g. `key`, `@key`, `@@key`, `$key`, or empty. */
20
+ export const LOOKUP_KEY = '(?:[@$]{0,2})[_a-zA-Z0-9-]*';
21
+
22
+ /** Zero or more chained lookups, e.g. `[a]`, `[a][b]`, `[@a][]`. */
23
+ export const LOOKUP_CHAIN = `(?:\\[${LOOKUP_KEY}\\])*`;
24
+
25
+ /** A variable name followed by an optional lookup chain, e.g. `map[@a][b]`. */
26
+ export const VARIABLE_WITH_LOOKUPS = `[\\w-]+${LOOKUP_CHAIN}`;
@@ -9,6 +9,28 @@ import logger from '../logger.js';
9
9
  import { DeprecationHandler } from '../deprecation.js';
10
10
  import Selector from '../tree/selector.js';
11
11
  import Anonymous from '../tree/anonymous.js';
12
+ import { VARIABLE_WITH_LOOKUPS } from './lookup-pattern.js';
13
+ import {
14
+ splitLookups,
15
+ resolveInterpolatedVariable,
16
+ resolveInterpolatedProperty,
17
+ hasInterpolation,
18
+ VARIABLE_INTERPOLATION,
19
+ PROPERTY_INTERPOLATION
20
+ } from '../tree/interpolated-variable.js';
21
+
22
+ /**
23
+ * One particle of a property name: a literal chunk, an `@{...}` interpolation
24
+ * which may carry a lookup chain, or a `${...}` interpolation which may not.
25
+ *
26
+ * The sigils are spelled out separately rather than sharing `[@$]`: properties
27
+ * have no lookup grammar, so accepting `${name[key]}` here would hand `name[key]`
28
+ * to `resolveInterpolatedProperty` and produce a misleading "undefined property"
29
+ * error for syntax the language does not define.
30
+ */
31
+ const RULE_PROPERTY_PARTICLE = new RegExp(
32
+ `^((?:[\\w-]+)|(?:@\\{${VARIABLE_WITH_LOOKUPS}\\})|(?:\\$\\{[\\w-]+\\}))`
33
+ );
12
34
 
13
35
  //
14
36
  // less.js - parser
@@ -106,6 +128,22 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
106
128
  warn('A bare @variable in an at-rule prelude is deprecated. Use @{variable} interpolation instead.', index, 'DEPRECATED', 'variable-in-at-rule-prelude');
107
129
  }
108
130
 
131
+ /**
132
+ * Whether a parsed entity is a bare `@variable` reference in a structural
133
+ * position, and so subject to the interpolation deprecation.
134
+ *
135
+ * A lookup such as `@map[key]` is parsed by `entities.variable()` into a
136
+ * `NamespaceValue` wrapping a `VariableCall`, not a `Variable`. Testing for
137
+ * `Variable` alone silently exempted every bare lookup from the deprecation.
138
+ *
139
+ * @param {{ type?: string } | undefined | null} e
140
+ * @returns {boolean}
141
+ */
142
+ function isBareVariableReference(e) {
143
+ if (!e) { return false; }
144
+ return e.type === 'Variable' || e.type === 'VariableCall' || e.type === 'NamespaceValue';
145
+ }
146
+
109
147
  /**
110
148
  * Numeric-leading variable names are a Less extension rather than valid CSS
111
149
  * identifier syntax. Keep accepting them through Less 4, but make the Less 5
@@ -717,6 +755,20 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
717
755
  value = this.quoted() || this.variable() || this.property() ||
718
756
  parserInput.$re(/^(?:(?:\\[()'"])|[^()'"])+/) || '';
719
757
 
758
+ // An unquoted url() body is otherwise raw text wrapped in an
759
+ // Anonymous node, which never substitutes anything — so
760
+ // `url(@{path}/a.png)` used to emit the braces verbatim while the
761
+ // quoted form resolved. Hand text containing interpolation to an
762
+ // escaped Quoted so both spellings resolve identically.
763
+ //
764
+ // The empty quote string matters: `URL.eval` escapes a rewritten
765
+ // rootpath only for unquoted values, so a real quote character here
766
+ // would suppress that and emit `url(a(b)/x.png)` unescaped. Escaped
767
+ // means no quote is written to the output either way.
768
+ if (typeof value === 'string' && hasInterpolation(value)) {
769
+ value = new(tree.Quoted)('', value, true, index + currentIndex, fileInfo);
770
+ }
771
+
720
772
  parserInput.autoCommentAbsorb = true;
721
773
 
722
774
  expectChar(')');
@@ -760,16 +812,44 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
760
812
  parserInput.restore();
761
813
  },
762
814
 
763
- // A variable entity using the protective {} e.g. @{var}
815
+ // A variable entity using the protective {} e.g. @{var}, optionally
816
+ // followed by a lookup chain e.g. @{map[key]} or @{map[@a][b]}.
817
+ //
818
+ // The chain is consumed by `mixin.ruleLookups()` rather than matched
819
+ // here, so the interpolated form shares one grammar with the bare
820
+ // `@map[key]` form instead of re-implementing it.
764
821
  variableCurly: function () {
765
822
  let curly;
766
823
  const index = parserInput.i;
767
824
 
768
- if (parserInput.currentChar() === '@' && (curly = parserInput.$re(/^@\{([\w-]+)\}/))) {
769
- warnNumericVariableName(curly[1], index);
770
- warnDashOnlyVariableName(curly[1], index);
771
- return new(tree.Variable)(`@${curly[1]}`, index + currentIndex, fileInfo);
825
+ if (parserInput.currentChar() !== '@') {
826
+ return;
827
+ }
828
+
829
+ parserInput.save();
830
+ if (!(curly = parserInput.$re(/^@\{([\w-]+)/))) {
831
+ parserInput.restore();
832
+ return;
772
833
  }
834
+
835
+ const name = curly[1];
836
+ const lookups = parsers.mixin.ruleLookups();
837
+
838
+ if (!parserInput.$char('}')) {
839
+ parserInput.restore();
840
+ return;
841
+ }
842
+
843
+ parserInput.forget();
844
+ warnNumericVariableName(name, index);
845
+ warnDashOnlyVariableName(name, index);
846
+
847
+ if (!lookups) {
848
+ return new(tree.Variable)(`@${name}`, index + currentIndex, fileInfo);
849
+ }
850
+
851
+ const call = new(tree.VariableCall)(`@${name}`, index + currentIndex, fileInfo);
852
+ return new(tree.NamespaceValue)(call, lookups, index + currentIndex, fileInfo);
773
853
  },
774
854
  //
775
855
  // A Property accessor, such as `$color`, in
@@ -1725,7 +1805,9 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
1725
1805
  merge = !isVariable && name.length > 1 && name.pop().value;
1726
1806
 
1727
1807
  // Custom property values get permissive parsing
1728
- if (name[0].value && name[0].value.slice(0, 2) === '--') {
1808
+ // A lookup particle (`NamespaceValue`) carries a node in `value`
1809
+ // rather than a string, and can never spell a `--` prefix.
1810
+ if (typeof name[0].value === 'string' && name[0].value.slice(0, 2) === '--') {
1729
1811
  if (parserInput.$char(';')) {
1730
1812
  value = new Anonymous('');
1731
1813
  } else {
@@ -1827,7 +1909,7 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
1827
1909
  if (!e) {
1828
1910
  const varIndex = parserInput.i;
1829
1911
  e = this.entity();
1830
- if (e && e.type === 'Variable') {
1912
+ if (isBareVariableReference(e)) {
1831
1913
  warnBareAtRuleVariable(varIndex);
1832
1914
  }
1833
1915
  }
@@ -1894,17 +1976,33 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
1894
1976
  const quote = new tree.Quoted('\'', item, true, index, fileInfo);
1895
1977
  const variableRegex = /@([\w-]+)/g;
1896
1978
  const propRegex = /\$([\w-]+)/g;
1979
+ // These notices are about *bare* references, so test the text
1980
+ // with interpolations removed. A lookup key is itself allowed
1981
+ // to be a variable (`@{map[@key]}`), and without this the
1982
+ // `@key` inside the braces would be misreported as a bare use
1983
+ // of the very syntax the notice tells you to adopt.
1984
+ const bareOnly = item
1985
+ .replace(VARIABLE_INTERPOLATION, '')
1986
+ .replace(PROPERTY_INTERPOLATION, '');
1897
1987
  // At-rule preludes are handled once above via
1898
1988
  // `value.bareVarIndex`; the `variable-in-unknown-value`
1899
1989
  // notice is for unknown declaration values only.
1900
- if (!deprecateVariables && variableRegex.test(item)) {
1990
+ if (!deprecateVariables && variableRegex.test(bareOnly)) {
1901
1991
  warn('@variable in unknown values will not be evaluated as variables in the future. Use @{variable}', index, 'DEPRECATED', 'variable-in-unknown-value');
1902
1992
  }
1903
- if (propRegex.test(item)) {
1993
+ if (propRegex.test(bareOnly)) {
1904
1994
  warn('$property in unknown values will not be evaluated as property references in the future. Use ${property}', index, 'DEPRECATED', 'property-in-unknown-value');
1905
1995
  }
1906
- quote.variableRegex = /@([\w-]+)|@{([\w-]+)}/g;
1907
- quote.propRegex = /\$([\w-]+)|\${([\w-]+)}/g;
1996
+ // Both alternatives carry the lookup chain. An unknown at-rule
1997
+ // prelude (`@supports`) is scanned as text, so a bare
1998
+ // `@map[key]` reaches this regex rather than being parsed
1999
+ // structurally; matching only `@map` would resolve it to the
2000
+ // whole ruleset and leave `[key]` behind as literal text.
2001
+ quote.variableRegex = new RegExp(
2002
+ `@(${VARIABLE_WITH_LOOKUPS})|@\\{(${VARIABLE_WITH_LOOKUPS})\\}`, 'g'
2003
+ );
2004
+ // Properties stay narrow — they have no lookup grammar.
2005
+ quote.propRegex = /\$([\w-]+)|\$\{([\w-]+)\}/g;
1908
2006
  result.push(quote);
1909
2007
  }
1910
2008
  }
@@ -2246,7 +2344,7 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
2246
2344
  if (curly) { return curly; }
2247
2345
  const index = parserInput.i;
2248
2346
  const e = this.entity();
2249
- if (e && e.type === 'Variable') {
2347
+ if (isBareVariableReference(e)) {
2250
2348
  warnBareAtRuleVariable(index);
2251
2349
  }
2252
2350
  return e;
@@ -2844,7 +2942,7 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
2844
2942
 
2845
2943
  match(/^(\*?)/);
2846
2944
  while (true) {
2847
- if (!match(/^((?:[\w-]+)|(?:[@$]\{[\w-]+\}))/)) {
2945
+ if (!match(RULE_PROPERTY_PARTICLE)) {
2848
2946
  break;
2849
2947
  }
2850
2948
  }
@@ -2860,16 +2958,22 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
2860
2958
  }
2861
2959
  for (k = 0; k < name.length; k++) {
2862
2960
  s = name[k];
2863
- if (s.charAt(0) === '@') {
2864
- const variableName = s.slice(2, -1);
2865
- warnNumericVariableName(variableName, index[k]);
2866
- warnDashOnlyVariableName(variableName, index[k]);
2867
- }
2868
- name[k] = (s.charAt(0) !== '@' && s.charAt(0) !== '$') ?
2869
- new(tree.Keyword)(s) :
2870
- (s.charAt(0) === '@' ?
2871
- new(tree.Variable)(`@${s.slice(2, -1)}`, index[k] + currentIndex, fileInfo) :
2872
- new(tree.Property)(`$${s.slice(2, -1)}`, index[k] + currentIndex, fileInfo));
2961
+ const sigil = s.charAt(0);
2962
+ if (sigil !== '@' && sigil !== '$') {
2963
+ name[k] = new(tree.Keyword)(s);
2964
+ continue;
2965
+ }
2966
+ // `@{name}` / `@{name[key]}` strip the sigil and braces, then
2967
+ // let the shared resolver decide between a plain reference and
2968
+ // a lookup so this path cannot drift from the others.
2969
+ const raw = s.slice(2, -1);
2970
+ if (sigil === '@') {
2971
+ warnNumericVariableName(splitLookups(raw).name, index[k]);
2972
+ warnDashOnlyVariableName(splitLookups(raw).name, index[k]);
2973
+ name[k] = resolveInterpolatedVariable(raw, index[k] + currentIndex, fileInfo);
2974
+ } else {
2975
+ name[k] = resolveInterpolatedProperty(raw, index[k] + currentIndex, fileInfo);
2976
+ }
2873
2977
  }
2874
2978
  return name;
2875
2979
  }
@@ -20,13 +20,17 @@ class Anonymous extends Node {
20
20
  this._fileInfo = currentFileInfo;
21
21
  this.mapLines = mapLines;
22
22
  this.rulesetLike = (typeof rulesetLike === 'undefined') ? false : rulesetLike;
23
+ /** @type {boolean} */
24
+ this._preventReparse = false;
23
25
  this.allowRoot = true;
24
26
  this.copyVisibilityInfo(visibilityInfo);
25
27
  }
26
28
 
27
29
  /** @returns {Anonymous} */
28
30
  eval() {
29
- return new Anonymous(/** @type {string | null} */ (this.value), this._index, this._fileInfo, this.mapLines, this.rulesetLike, this.visibilityInfo());
31
+ const anonymous = new Anonymous(/** @type {string | null} */ (this.value), this._index, this._fileInfo, this.mapLines, this.rulesetLike, this.visibilityInfo());
32
+ anonymous._preventReparse = this._preventReparse;
33
+ return anonymous;
30
34
  }
31
35
 
32
36
  /**
@@ -0,0 +1,106 @@
1
+ // @ts-check
2
+ /** @import { FileInfo } from './node.js' */
3
+ /** @import Node from './node.js' */
4
+ import Variable from './variable.js';
5
+ import Property from './property.js';
6
+ import VariableCall from './variable-call.js';
7
+ import NamespaceValue from './namespace-value.js';
8
+ import { LOOKUP_KEY, VARIABLE_WITH_LOOKUPS } from '../parser/lookup-pattern.js';
9
+
10
+ /**
11
+ * Interpolation resolved by string replacement at eval time.
12
+ *
13
+ * The parser consumes `@{name[key]}` structurally via `entities.variableCurly`,
14
+ * but `Quoted` and inline JavaScript hold their contents as raw text and can only
15
+ * substitute at eval time. Both share this module so the two evaluation paths build
16
+ * identical nodes from identical patterns.
17
+ */
18
+
19
+ /** Matches `@{name}` and `@{name[a][b]}`, capturing the reference. */
20
+ export const VARIABLE_INTERPOLATION = new RegExp(`@\\{(${VARIABLE_WITH_LOOKUPS})\\}`, 'g');
21
+
22
+ /**
23
+ * Matches `${name}`, capturing the reference.
24
+ *
25
+ * Deliberately narrower than {@link VARIABLE_INTERPOLATION}: a lookup chain is not
26
+ * part of the property grammar. `entities.property` parses `$name` with no lookup
27
+ * handling, so `$map[key]` is a property reference followed by the literal text
28
+ * `[key]`, and a property cannot hold a ruleset to look into in the first place
29
+ * (`prop: { … }` is a parse error in every scope). Accepting `${map[key]}` here
30
+ * would invent syntax the language does not have.
31
+ */
32
+ export const PROPERTY_INTERPOLATION = /\$\{([\w-]+)\}/g;
33
+
34
+ const LOOKUP_SEGMENT = new RegExp(`\\[(${LOOKUP_KEY})\\]`, 'g');
35
+
36
+ // Non-global twins for membership tests. `RegExp.test` on a /g regex advances
37
+ // `lastIndex` and so returns alternating results across calls on shared instances.
38
+ const HAS_VARIABLE_INTERPOLATION = new RegExp(VARIABLE_INTERPOLATION.source);
39
+ const HAS_PROPERTY_INTERPOLATION = new RegExp(PROPERTY_INTERPOLATION.source);
40
+
41
+ /**
42
+ * Whether text contains an `@{...}` or `${...}` interpolation.
43
+ *
44
+ * @param {string} text
45
+ * @returns {boolean}
46
+ */
47
+ export function hasInterpolation(text) {
48
+ return HAS_VARIABLE_INTERPOLATION.test(text) || HAS_PROPERTY_INTERPOLATION.test(text);
49
+ }
50
+
51
+ /**
52
+ * Split a `name[a][b]` reference into its name and lookup keys.
53
+ *
54
+ * `lookups` is null for a plain reference, so callers keep using the cheaper
55
+ * `Variable`/`Property` node when there is no lookup to resolve.
56
+ *
57
+ * @param {string} raw
58
+ * @returns {{ name: string, lookups: string[] | null }}
59
+ */
60
+ export function splitLookups(raw) {
61
+ const open = raw.indexOf('[');
62
+ if (open === -1) {
63
+ return { name: raw, lookups: null };
64
+ }
65
+ /** @type {string[]} */
66
+ const lookups = [];
67
+ const re = new RegExp(LOOKUP_SEGMENT.source, 'g');
68
+ let match;
69
+ while ((match = re.exec(raw)) !== null) {
70
+ lookups.push(match[1]);
71
+ }
72
+ return { name: raw.slice(0, open), lookups };
73
+ }
74
+
75
+ /**
76
+ * Build the node for an interpolated `@variable` reference, with or without lookups.
77
+ *
78
+ * @param {string} raw - the reference text inside `@{...}`
79
+ * @param {number} index
80
+ * @param {FileInfo} fileInfo
81
+ * @returns {Node}
82
+ */
83
+ export function resolveInterpolatedVariable(raw, index, fileInfo) {
84
+ const { name, lookups } = splitLookups(raw);
85
+ if (!lookups) {
86
+ return new Variable(`@${name}`, index, fileInfo);
87
+ }
88
+ return new NamespaceValue(
89
+ new VariableCall(`@${name}`, index, fileInfo), lookups, index, fileInfo
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Build the node for an interpolated `$property` reference.
95
+ *
96
+ * No lookup handling: see {@link PROPERTY_INTERPOLATION}. Properties have no lookup
97
+ * grammar, so `raw` is always a bare name here.
98
+ *
99
+ * @param {string} raw - the reference text inside `${...}`
100
+ * @param {number} index
101
+ * @param {FileInfo} fileInfo
102
+ * @returns {Node}
103
+ */
104
+ export function resolveInterpolatedProperty(raw, index, fileInfo) {
105
+ return new Property(`$${raw}`, index, fileInfo);
106
+ }
@@ -2,6 +2,10 @@
2
2
  /** @import { EvalContext } from './node.js' */
3
3
  import Node from './node.js';
4
4
  import Variable from './variable.js';
5
+ import {
6
+ VARIABLE_INTERPOLATION,
7
+ resolveInterpolatedVariable
8
+ } from './interpolated-variable.js';
5
9
 
6
10
  class JsEvalNode extends Node {
7
11
  /**
@@ -21,8 +25,10 @@ class JsEvalNode extends Node {
21
25
  index: this.getIndex() };
22
26
  }
23
27
 
24
- expression = expression.replace(/@\{([\w-]+)\}/g, function (_, name) {
25
- return that.jsify(new Variable(`@${name}`, that.getIndex(), that.fileInfo()).eval(context));
28
+ expression = expression.replace(VARIABLE_INTERPOLATION, function (_, raw) {
29
+ return that.jsify(resolveInterpolatedVariable(
30
+ raw, that.getIndex(), that.fileInfo()
31
+ ).eval(context));
26
32
  });
27
33
 
28
34
  /** @type {Function} */
@@ -151,6 +151,10 @@ class MixinCall extends Node {
151
151
  for (m = 0; m < expandedValues.length; m++) {
152
152
  args.push({value: expandedValues[m]});
153
153
  }
154
+ } else if (argValue.type === 'Expression' && Array.isArray(argValue.value) && argValue.value.length === 0) {
155
+ // an unset variadic holds no captured args; omit it so the callee
156
+ // falls through to its own parameter defaults instead of receiving empty
157
+
154
158
  } else {
155
159
  args.push({name: arg.name, value: argValue});
156
160
  }
@@ -37,6 +37,40 @@ import Node from './node.js';
37
37
  * }} NestableAtRuleThis
38
38
  */
39
39
 
40
+ // A media query may only carry a media type at its front, before any
41
+ // conditions (https://drafts.csswg.org/mediaqueries-5/#typedef-media-query-list).
42
+ // Operators that join media-query parts, so a fragment leading with one is not
43
+ // a media type. Any other bare identifier is treated as a type, since unknown
44
+ // media types are valid non-matches in CSS, not syntax errors.
45
+ const MEDIA_QUERY_OPERATORS = ['and', 'or'];
46
+
47
+ /**
48
+ * Whether a flattened media-query fragment leads with a media type, e.g.
49
+ * `screen`, `only screen`, `print and (color)` or an unknown type like `foo`.
50
+ * @param {Node & { value?: * }} fragment
51
+ */
52
+ function startsWithMediaType(fragment) {
53
+ let head;
54
+ if (fragment.type === 'Keyword' || fragment.type === 'Anonymous') {
55
+ head = fragment.value;
56
+ } else if (fragment.type === 'Expression' && Array.isArray(fragment.value)) {
57
+ const parts = fragment.value.filter(p => p && p.value !== undefined);
58
+ let idx = 0;
59
+ const first = parts[idx] && String(parts[idx].value).toLowerCase();
60
+ if (first === 'not' || first === 'only') { idx++; }
61
+ head = parts[idx] && parts[idx].value;
62
+ }
63
+ if (typeof head !== 'string' || head === '') {
64
+ return false;
65
+ }
66
+ // Inspect only the first token. A media type is a bare identifier; a feature
67
+ // condition begins with '(' - e.g. an escaped ~"(max-width: 1px)" is an
68
+ // Anonymous whose whole value is "(max-width: 1px)", which is not a media type.
69
+ const firstToken = head.trim().split(/[\s(]/)[0];
70
+ return firstToken !== ''
71
+ && MEDIA_QUERY_OPERATORS.indexOf(firstToken.toLowerCase()) < 0;
72
+ }
73
+
40
74
  const NestableAtRulePrototype = {
41
75
 
42
76
  isRulesetLike() {
@@ -149,6 +183,13 @@ const NestableAtRulePrototype = {
149
183
  /** @param {Node & { toCSS?: Function }} fragment */
150
184
  fragment => fragment.toCSS ? fragment : new Anonymous(/** @type {string} */ (/** @type {unknown} */ (fragment))));
151
185
 
186
+ // A media type nested inside conditions must move ahead of them
187
+ // so the flattened query stays valid (issue #3694, #3764).
188
+ const types = /** @type {Node[]} */ (path).filter(startsWithMediaType);
189
+ if (types.length && types.length < /** @type {Node[]} */ (path).length) {
190
+ path = types.concat(/** @type {Node[]} */ (path).filter(f => !startsWithMediaType(f)));
191
+ }
192
+
152
193
  for (i = /** @type {Node[]} */ (path).length - 1; i > 0; i--) {
153
194
  /** @type {Node[]} */ (path).splice(i, 0, new Anonymous('and'));
154
195
  }
@@ -1,8 +1,12 @@
1
1
  // @ts-check
2
2
  /** @import { EvalContext, CSSOutput, FileInfo } from './node.js' */
3
3
  import Node from './node.js';
4
- import Variable from './variable.js';
5
- import Property from './property.js';
4
+ import {
5
+ VARIABLE_INTERPOLATION,
6
+ PROPERTY_INTERPOLATION,
7
+ resolveInterpolatedVariable,
8
+ resolveInterpolatedProperty
9
+ } from './interpolated-variable.js';
6
10
 
7
11
  class Quoted extends Node {
8
12
  get type() { return 'Quoted'; }
@@ -25,9 +29,9 @@ class Quoted extends Node {
25
29
  this._index = index;
26
30
  this._fileInfo = currentFileInfo;
27
31
  /** @type {RegExp} */
28
- this.variableRegex = /@\{([\w-]+)\}/g;
32
+ this.variableRegex = new RegExp(VARIABLE_INTERPOLATION.source, 'g');
29
33
  /** @type {RegExp} */
30
- this.propRegex = /\$\{([\w-]+)\}/g;
34
+ this.propRegex = new RegExp(PROPERTY_INTERPOLATION.source, 'g');
31
35
  /** @type {boolean | undefined} */
32
36
  this.allowRoot = escaped;
33
37
  }
@@ -62,7 +66,9 @@ class Quoted extends Node {
62
66
  * @returns {string}
63
67
  */
64
68
  const variableReplacement = function (_, name1, name2) {
65
- const v = new Variable(`@${name1 ?? name2}`, that.getIndex(), that.fileInfo()).eval(context);
69
+ const v = resolveInterpolatedVariable(
70
+ name1 ?? name2, that.getIndex(), that.fileInfo()
71
+ ).eval(context);
66
72
  return (v instanceof Quoted) ? /** @type {string} */ (v.value) : v.toCSS(context);
67
73
  };
68
74
  /**
@@ -72,7 +78,9 @@ class Quoted extends Node {
72
78
  * @returns {string}
73
79
  */
74
80
  const propertyReplacement = function (_, name1, name2) {
75
- const v = new Property(`$${name1 ?? name2}`, that.getIndex(), that.fileInfo()).eval(context);
81
+ const v = resolveInterpolatedProperty(
82
+ name1 ?? name2, that.getIndex(), that.fileInfo()
83
+ ).eval(context);
76
84
  return (v instanceof Quoted) ? /** @type {string} */ (v.value) : v.toCSS(context);
77
85
  };
78
86
  /**
@@ -91,7 +99,16 @@ class Quoted extends Node {
91
99
  }
92
100
  value = iterativeReplace(value, this.variableRegex, variableReplacement);
93
101
  value = iterativeReplace(value, this.propRegex, propertyReplacement);
94
- return new Quoted(this.quote + value + this.quote, value, this.escaped, this.getIndex(), this.fileInfo());
102
+ const result = new Quoted(
103
+ this.quote + value + this.quote, value, this.escaped, this.getIndex(), this.fileInfo()
104
+ );
105
+ // Carry the quote across rather than re-deriving it from the rebuilt string.
106
+ // For a real quote character the two agree, but an unquoted body (empty quote)
107
+ // is not round-trippable that way — it would pick up the first character of the
108
+ // substituted value and read as quoted, which suppresses rootpath escaping in
109
+ // `URL.eval`.
110
+ result.quote = this.quote;
111
+ return result;
95
112
  }
96
113
 
97
114
  /**
@@ -426,7 +426,9 @@ class Ruleset extends Node {
426
426
  const self = this;
427
427
  /** @param {Declaration} decl */
428
428
  function transformDeclaration(decl) {
429
- if (decl.value instanceof Anonymous && !/** @type {Declaration & { parsed?: boolean }} */ (decl).parsed) {
429
+ if (decl.value instanceof Anonymous &&
430
+ !decl.value._preventReparse &&
431
+ !/** @type {Declaration & { parsed?: boolean }} */ (decl).parsed) {
430
432
  if (typeof decl.value.value === 'string') {
431
433
  new (/** @type {new (...args: [EvalContext, object, FileInfo, number]) => { parseNode: Function }} */ (/** @type {unknown} */ (Parser)))(/** @type {{ context: EvalContext, importManager: object }} */ (/** @type {Ruleset} */ (this).parse).context, /** @type {{ context: EvalContext, importManager: object }} */ (/** @type {Ruleset} */ (this).parse).importManager, decl.fileInfo(), decl.value.getIndex()).parseNode(
432
434
  decl.value.value,