@pre.style/core 3.1.0-alpha.3 → 3.1.0-alpha.5

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.
package/README.md CHANGED
@@ -246,14 +246,10 @@ css`
246
246
  }
247
247
  ```
248
248
 
249
- Values work in nested selectors too, including pseudo-elements and other elements. The generated class's rule uses the same selector, so the value reaches them. `content` needs a string, so pass one:
249
+ Values work in nested selectors too, including pseudo-elements and other elements. The generated class's rule uses the same selector, so the value reaches them:
250
250
 
251
251
  ```js
252
252
  css`
253
- &::before {
254
- content: var(${JSON.stringify(String(count))}, "0");
255
- }
256
-
257
253
  & > * {
258
254
  margin-top: ${gap}px;
259
255
  }
@@ -266,11 +262,38 @@ css`
266
262
 
267
263
  ```css
268
264
  /* Inserted by the runtime */
269
- ._A3fK9::before {
270
- --A: "5";
265
+ ._A3fK9 > * {
266
+ --A: 8px;
267
+ }
268
+ ```
269
+
270
+ #### Strings and URLs
271
+
272
+ Write quotes around `${…}` for a string, with any text you like. The runtime escapes the value, and numbers work too. `url()` needs its quotes:
273
+
274
+ ```js
275
+ css`
276
+ background-image: url("/img/${name}.png");
277
+
278
+ &::before {
279
+ content: var("Count: ${count}", "Loading");
280
+ }
281
+ `;
282
+ ```
283
+
284
+ ```css
285
+ /* Inserted by the runtime */
286
+ ._A3fK9 {
287
+ --A: url("/img/photo.png");
288
+ }
289
+
290
+ ._B7xQ2::before {
291
+ --B: "Count: 5";
271
292
  }
272
293
  ```
273
294
 
295
+ The whole string is the value, so a default goes around it, as above. `content: ${x}` without quotes doesn't add any: use it for values that are already CSS, such as `none` or `counter(n)`. In `scss` blocks, `#{…}` can't be in the same value as `${…}`.
296
+
274
297
  #### Custom properties
275
298
 
276
299
  Name a custom property to share a value: with other elements, which inherit it, with something else that reads that name, or across several declarations in a block. For a value that's only used on one element, write `${…}` directly instead. A name is global, so two blocks using the same one can affect each other. To avoid that, use distinctive names, or [`customPropertyLocalization`](#local-custom-properties).
@@ -408,18 +431,7 @@ css`
408
431
  margin-${side}: 0; /* property names */
409
432
  @media (min-width: ${bp}px) { top: 0; } /* at-rule conditions, see Sharing media queries */
410
433
  @font-face { font-weight: ${weight}; } /* @font-face, @property, @page, and similar */
411
- content: "${label}"; /* quoted strings */
412
- background-image: url(${path}); /* url() */
413
- `;
414
- ```
415
-
416
- For strings and URLs, pass the whole value:
417
-
418
- ```js
419
- css`
420
- content: ${JSON.stringify(label)};
421
- --image: ${`url("${path}")`};
422
- background-image: var(--image);
434
+ background-image: url(${path}); /* url() without quotes */
423
435
  `;
424
436
  ```
425
437
 
@@ -448,16 +460,30 @@ In older browsers:
448
460
 
449
461
  #### When to use `style` instead
450
462
 
451
- Every new value adds a rule that's never removed. That suits values with a limited set of results, such as a theme color or a chosen size. For values that change constantly, such as a drag position or anything set per frame, use the `style` attribute and read it from the block:
463
+ A block compiles to a class string, so the runtime can't tell which element it's for. That's why each distinct value gets its own class, rather than one class whose value changes: two elements from the same block can have different values, as in a list. So the class changes with the value, and every new value adds a rule that's never removed.
464
+
465
+ That suits values from a small set, such as a theme color or a chosen size, where going back to a value reuses its class. For values that change constantly, such as a counter, a drag position, or anything set per frame, use the `style` attribute and read it from a static block. The class never changes, no rules are added, the server renders the real value, and no runtime is needed:
452
466
 
453
467
  ```jsx
454
468
  const box = css`
455
- transform: translateX(var(--x));
469
+ transform: translateX(var(--x, 0));
456
470
  `;
457
471
 
458
472
  <div className={box} style={{ "--x": `${x}px` }} />;
459
473
  ```
460
474
 
475
+ | | `${…}` | `style` |
476
+ | --- | --- | --- |
477
+ | The class | Changes with the value | Stays the same |
478
+ | CSS rules | One per distinct value, kept | None added |
479
+ | On the server | The default, until the runtime runs | The real value |
480
+ | In `cn` | Later values win | Not involved |
481
+ | Runtime | Needed | Not needed |
482
+ | In pseudo-elements and other elements | Reaches them | Only if the property inherits |
483
+ | Best for | Values from a small set | Values that change constantly |
484
+
485
+ The demos' Interpolation pages show both side by side.
486
+
461
487
  ### Selectors
462
488
 
463
489
  Use `&` for the element, as in native CSS nesting. Anything that isn't the bare element (pseudo-classes, pseudo-elements, and complex selectors) becomes a selector variant:
@@ -896,13 +922,40 @@ Expanding means some shorthands produce many classes. For example, `font` also r
896
922
 
897
923
  ### Order
898
924
 
899
- Within one block, a later declaration overrides an earlier one, as in plain CSS. Across blocks, the CSS file is ordered so the usual rules hold:
925
+ Within one block, a later declaration overrides an earlier one, as in plain CSS (see [Fallbacks](#fallbacks) for the same property twice in a row). Across blocks, the CSS file is ordered so the usual rules hold:
900
926
 
901
927
  1. Hoisted at-rules (`@layer` statements, `@import`, `@namespace`), then other copied at-rules. With the `layer` option, everything but `@import` and `@namespace` is inside the layer.
902
928
  2. Classes on the element.
903
929
  3. Selector variants, with pseudo-classes in LVHFA order (`:link`, `:visited`, `:hover`, `:focus`, `:active`).
904
930
  4. At-rule groups. `@media` queries with only a `min-width` come last, smallest first (mobile-first).
905
931
 
932
+ ### Fallbacks
933
+
934
+ Declaring the same property more than once in a rule keeps the earlier values as fallbacks, for browsers that can't parse the later ones. They stay together in one class:
935
+
936
+ ```ts
937
+ css`
938
+ height: 100%;
939
+ height: 100svh;
940
+ `;
941
+ ```
942
+
943
+ ```css
944
+ .A {
945
+ height: 100%;
946
+ height: 100svh;
947
+ }
948
+ ```
949
+
950
+ Lightning CSS drops the fallbacks your [`targets`](#configuration) don't need, so set `targets` to keep them. Without `targets`, it assumes current browsers and writes only `height: 100svh` (which then shares a class with any other `height: 100svh`). Values it can't parse, such as `calc-size()`, always keep their fallbacks.
951
+
952
+ A few things aren't fallbacks:
953
+
954
+ - **A declaration in another rule**, such as `height: 100%; & { height: 100svh; }`, overrides the earlier one, since separate rules become separate classes.
955
+ - **A value with `var()`, `env()`, or `${…}`** replaces the declarations before it. Browsers only check these values when they're used, so they never fall back: an invalid value, or a `${…}` that is `null`, makes the property `unset`. To fall back from `${…}`, use a default (`height: var(${h}, 100%)`). A `${…}` followed by a static value is a real fallback (`height: ${h}; height: 100svh`).
956
+ - **Custom properties**, which accept any value.
957
+ - **Shorthands** are expanded into longhands, so `background: red; background: linear-gradient(…)` loses the `red`: the gradient's `background-color` (transparent) is a valid value in every browser.
958
+
906
959
  ## Compared to utility-first frameworks
907
960
 
908
961
  Utility-first frameworks such as [Tailwind CSS](https://tailwindcss.com/) also produce atomic CSS, so the same styles cost about the same in either. The differences are elsewhere:
@@ -980,6 +1033,7 @@ preStyle({
980
1033
  | --------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
981
1034
  | `lightningcss` | | [Lightning CSS options](https://lightningcss.dev/transforms.html), such as `minify`, `targets`, `drafts`, and `visitor`. A `visitor` runs before pre.style processes each block. `filename`, `code`, `sourceMap`, `projectRoot`, `inputSourceMap`, and `cssModules` are ignored. |
982
1035
  | `sourceMap` | `true` in dev, `false` in builds | Generate a source map for the CSS, mapping each class to the first block it appears in (a source map can only point to one place; the [DevTools extension](#tracing-classes-with-the-devtools-extension) lists them all). In dev, it's shown in the browser (the plugin turns on Vite's `css.devSourcemap`, unless you set it). In builds, it's written next to `output.file`. When you import `virtual:pre.style.css`, builds have no CSS source map, because Vite doesn't write them for bundled CSS. |
1036
+ | `sourceAttributes` | `true` in dev, except in test runs | In dev, mark each `className`/`class` attribute in JSX and Astro components with where it's written (as `data-pre-style-at` on elements and `data-pre-style-from` on components), so the [DevTools extension](#tracing-classes-with-the-devtools-extension) can link an element to the line that set its classes. Needs `sourceMap`. Off by default in Vite's `test` mode (as in Vitest), so rendered markup and snapshots don't change; set it to `true` to have them there too. Builds never have the attributes. |
983
1037
  | `layer` | `false` | Wrap the CSS in a [cascade layer](https://developer.mozilla.org/docs/Web/CSS/@layer): `false` for none, `""` for an unnamed layer, or the layer's name, such as `"pre.style"` or `"vendor.pre-style"`. `@import` and `@namespace` rules stay before the layer, since they can't be inside one. `@layer` rules from blocks become layers nested in it. Browsers without cascade layers (Chrome before 99, Safari before 15.4, Firefox before 97) ignore everything inside it. |
984
1038
  | `quotes` | `'double-quote'` | Quotes around the class string that replaces each block in JS: `'single-quote'`, `'double-quote'`, or `'backtick'`. |
985
1039
  | `output.file` | `'pre.style.css'` | Path and name of the CSS file, relative to Vite's `build.outDir`. |
@@ -994,7 +1048,15 @@ Before 3.1, `include`, `exclude`, `prependFiles`, `libraries`, and `quirksMode`
994
1048
 
995
1049
  Classes are shared, so one class can come from many blocks, and a source map can only point to one of them. The [pre.style DevTools extension](packages/devtools) adds a **pre.style** tab to the Elements panel in Chrome and Firefox. For the selected element, it lists each pre.style rule like the Styles pane does, with a link to every block that uses it.
996
1050
 
997
- It works with the Vite dev server while `sourceMap` is on, which is the default in dev. Setting `sourceMap: false` turns it off. It only runs on `localhost`, `*.localhost`, loopback addresses, and hostnames you add in its settings, and sends other sites no requests.
1051
+ It works with the Vite dev server while `sourceMap` is on, which is the default in dev. Setting `sourceMap: false` turns it off.
1052
+
1053
+ It also links to where the element got its classes: the `className` or `class` attribute on the element, and the one on the component it was passed to, when the component passes its props on. For this, the plugin adds `data-pre-style-at` and `data-pre-style-from` attributes to your JSX and Astro components in dev (not in builds or test runs), which you'll see in the Elements panel. If you'd rather not see them, turn them off with the `sourceAttributes` option.
1054
+
1055
+ ```ts
1056
+ preStyle({ sourceAttributes: false });
1057
+ ```
1058
+
1059
+ It only runs on `localhost`, `*.localhost`, loopback addresses, and hostnames you add in its settings, and sends other sites no requests.
998
1060
 
999
1061
  Its links open your editor through Vite, which guesses which one you use. When the dev server runs in VS Code's terminal, the plugin sets the `LAUNCH_EDITOR` environment variable to `code` if it isn't set, which also applies to Vite's error overlay. To choose an editor yourself, set `LAUNCH_EDITOR`.
1000
1062
 
@@ -16,15 +16,17 @@ export interface Atom {
16
16
  key: string;
17
17
  selectors: SelectorList;
18
18
  } | null;
19
- declaration: {
20
- key: string;
21
- value: Declaration;
22
- important: boolean;
23
- };
19
+ declaration: AtomDeclaration;
24
20
  property: string;
25
21
  shorthand: boolean;
26
22
  position: Position | null;
27
23
  }
24
+ export interface AtomDeclaration {
25
+ key: string;
26
+ values: Declaration[];
27
+ important: boolean;
28
+ }
29
+ export declare const atomDeclaration: (values: Declaration[], important: boolean) => AtomDeclaration;
28
30
  export interface GlobalRule {
29
31
  key: string;
30
32
  rule: Rule;
@@ -77,6 +79,7 @@ export interface ParseOptions {
77
79
  export declare const key: (value: unknown) => string;
78
80
  export declare function parseBlock(code: string, lang: Lang, options: ParseOptions): ParsedBlock;
79
81
  export declare function parseRules(css: string, lightningcss: LightningOptions, position: ToPosition, collect: (rules: Rule[]) => void): void;
82
+ export declare function pruneFallbacks(atoms: Atom[], lightningcss: LightningOptions): Atom[];
80
83
  export declare function dropOverridden(atoms: Atom[]): Atom[];
81
84
  export type ToPosition = (loc: {
82
85
  line: number;
@@ -5,9 +5,16 @@ import { localizeCustomProperties } from './custom-properties.js';
5
5
  import { SLOT_PREFIX } from './interpolate.js';
6
6
  import { localizeKeyframes } from './keyframes.js';
7
7
  import { compose, fromPlaceholder, isElement, PLACEHOLDER, repair } from './selectors.js';
8
+ import { print } from './print.js';
8
9
  import { splitBlock } from './split.js';
9
10
  import { extractVariables } from './variables.js';
10
11
  import { SHORTHANDS } from '../shorthands.js';
12
+ // An atom's declaration. A single declaration's key is its own key.
13
+ export const atomDeclaration = (values, important) => ({
14
+ key: key(values.length === 1 ? values[0] : values) + (important ? '!' : ''),
15
+ values,
16
+ important,
17
+ });
11
18
  export class PreStyleError extends Error {
12
19
  position;
13
20
  constructor(message, position, options) {
@@ -86,7 +93,7 @@ export function parseBlock(code, lang, options) {
86
93
  // Sass output positions don't match the block, so scss atoms have none.
87
94
  const position = (loc) => lang === 'css' && loc ? toBlock(loc.line, loc.column) : null;
88
95
  parseRules(css, options.lightningcss, position, (rules) => walk(rules, [], null, parsed, position));
89
- let atoms = dropOverridden(parsed.atoms);
96
+ let atoms = dropOverridden(pruneFallbacks(parsed.atoms, options.lightningcss));
90
97
  let { globals } = parsed;
91
98
  if (options.customPropertyLocalization) {
92
99
  const local = localizeCustomProperties(atoms, globals, `block\n${code}`, lang, options);
@@ -127,6 +134,60 @@ export function parseRules(css, lightningcss, position, collect) {
127
134
  throw new PreStyleError(`Invalid CSS: ${error.message}`, loc ? position({ line: loc.line - 1, column: loc.column }) : null, { cause: error });
128
135
  }
129
136
  }
137
+ // Drops the fallbacks in each stack that Lightning CSS wouldn't print for the
138
+ // user's targets, so a stack that collapses to one declaration shares its
139
+ // class. Values Lightning CSS can't parse (such as `calc-size()`) are kept.
140
+ // Printing and parsing back changes some values' trees, so the round trip
141
+ // only decides which of the original declarations are kept.
142
+ export function pruneFallbacks(atoms, lightningcss) {
143
+ if (atoms.every((atom) => atom.declaration.values.length === 1))
144
+ return atoms;
145
+ const { visitor: _visitor, minify: _minify, ...options } = lightningcss;
146
+ // The stack as Lightning CSS prints it, parsed back, as keys. Declarations
147
+ // it adds for other properties (vendor prefixes) are left out.
148
+ const printed = (stack, important, property) => {
149
+ const block = important ? { importantDeclarations: stack } : { declarations: stack };
150
+ const rule = {
151
+ type: 'style',
152
+ value: {
153
+ selectors: [[{ type: 'class', name: 'x' }]],
154
+ declarations: block,
155
+ rules: [],
156
+ loc: { source_index: 0, line: 0, column: 1 },
157
+ },
158
+ };
159
+ let values = [];
160
+ parseRules(print([rule], [], options, false).code, options, () => null, (rules) => {
161
+ const parsed = valueOf(rules[0]).declarations ?? {};
162
+ values = (important ? parsed.importantDeclarations : parsed.declarations) ?? [];
163
+ });
164
+ return values.filter((value) => propertyOf(value).property === property).map(key);
165
+ };
166
+ return atoms.map((atom) => {
167
+ const { values: stack, important } = atom.declaration;
168
+ if (stack.length === 1)
169
+ return atom;
170
+ let kept;
171
+ try {
172
+ const remaining = printed(stack, important, atom.property);
173
+ if (remaining.length === stack.length)
174
+ return atom;
175
+ kept = stack.filter((value) => {
176
+ const own = printed([value], important, atom.property).at(-1);
177
+ if (own !== remaining[0])
178
+ return false;
179
+ remaining.shift();
180
+ return true;
181
+ });
182
+ }
183
+ catch {
184
+ return atom;
185
+ }
186
+ if (kept.length === 0)
187
+ return atom;
188
+ return { ...atom, declaration: atomDeclaration(kept, important) };
189
+ });
190
+ }
130
191
  // Within a block, a later declaration overrides an earlier one that sets the
131
192
  // same property under the same at-rules and selector variant, as in CSS.
132
193
  // `!important` declarations are collected after normal ones in each rule,
@@ -225,24 +286,43 @@ export function addDeclarations(block, atRules, selectors, atoms, composes, posi
225
286
  composes.push(parseComposes(d, position));
226
287
  normal = normal.slice(own);
227
288
  }
289
+ // Declarations of the same property in this list are stacked, in order, as
290
+ // fallbacks, at the place of the last one. A declaration that always parses
291
+ // replaces the stack, since nothing before it can ever apply.
228
292
  const add = (declarations = [], important) => {
293
+ const stacks = new Map();
294
+ const list = [];
229
295
  for (const declaration of declarations) {
230
296
  for (const value of expand(declaration)) {
231
297
  const { property, shorthand } = propertyOf(value);
232
- atoms.push({
233
- atRules,
234
- variant,
235
- declaration: { key: key(value) + (important ? '!' : ''), value, important },
236
- property,
237
- shorthand,
238
- position,
239
- });
298
+ const stack = stacks.get(property);
299
+ if (stack)
300
+ list.splice(list.findIndex((item) => item.values === stack), 1);
301
+ const values = stack && !alwaysParses(value, property) ? [...stack, value] : [value];
302
+ stacks.set(property, values);
303
+ list.push({ property, shorthand, values });
240
304
  }
241
305
  }
306
+ for (const { property, shorthand, values } of list)
307
+ atoms.push({
308
+ atRules,
309
+ variant,
310
+ declaration: atomDeclaration(values, important),
311
+ property,
312
+ shorthand,
313
+ position,
314
+ });
242
315
  };
243
316
  add(normal, false);
244
317
  add(block?.importantDeclarations, true);
245
318
  }
319
+ // Whether a browser always parses a declaration, so it can't fall back to an
320
+ // earlier one: custom properties take any value, and `var()` and `env()`
321
+ // (including `${…}`, which becomes a `var()`) are only checked once they're
322
+ // substituted, when an invalid value makes the property `unset`.
323
+ function alwaysParses(value, property) {
324
+ return property.startsWith('--') || /"type":"(?:var|env)"/.test(key(value));
325
+ }
246
326
  const ident = (t) => t?.type === 'token' && t.value.type === 'ident' ? t.value.value : null;
247
327
  // Reads `composes: a b`, `composes: a from './file.css'`, or
248
328
  // `composes: a from global`.
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, relative, resolve as resolvePath, sep } from 'node:path';
3
3
  import { transform } from 'lightningcss';
4
4
  import SweatMap from 'sweatmap';
5
- import { dropOverridden, parseBlock, PreStyleError, valueOf } from './block.js';
5
+ import { atomDeclaration, dropOverridden, parseBlock, PreStyleError, valueOf } from './block.js';
6
6
  import { interpolate } from './interpolate.js';
7
7
  import { print } from './print.js';
8
8
  import { lvhfaRank, specificity, withClass } from './selectors.js';
@@ -544,12 +544,12 @@ export class Compiler {
544
544
  }
545
545
  #atomRule(record, loc) {
546
546
  const { atom } = record;
547
- const value = hasGenerated(atom.declaration.key)
548
- ? this.#named(atom.declaration.value)
549
- : atom.declaration.value;
547
+ const values = hasGenerated(atom.declaration.key)
548
+ ? this.#named(atom.declaration.values)
549
+ : atom.declaration.values;
550
550
  const declarations = atom.declaration.important
551
- ? { importantDeclarations: [value] }
552
- : { declarations: [value] };
551
+ ? { importantDeclarations: values }
552
+ : { declarations: values };
553
553
  return {
554
554
  type: 'style',
555
555
  value: { selectors: this.#atomSelectors(record), declarations, rules: [], loc },
@@ -574,6 +574,20 @@ export class Compiler {
574
574
  };
575
575
  return emit(this.#atomGroups());
576
576
  }
577
+ // An atom's rule as text. Without the user's targets, Lightning CSS would
578
+ // drop a stack's fallbacks, so each declaration is printed on its own.
579
+ #printedStack(record, rule, text) {
580
+ const code = text(rule);
581
+ const { values, important } = record.atom.declaration;
582
+ if (values.length === 1)
583
+ return code;
584
+ const lines = values.map((value) => {
585
+ const atom = { ...record.atom, declaration: atomDeclaration([value], important) };
586
+ const own = text(this.#atomRule({ ...record, atom }, valueOf(rule).loc));
587
+ return own.slice(own.indexOf(' {\n') + 3, own.lastIndexOf('\n}'));
588
+ });
589
+ return `${code.slice(0, code.indexOf(' {\n'))} {\n${lines.join('\n')}\n}`;
590
+ }
577
591
  #usage(record, order) {
578
592
  const { atom } = record;
579
593
  const loc = { source_index: 0, line: 0, column: 1 };
@@ -581,7 +595,7 @@ export class Compiler {
581
595
  // Printed for reading, so without the user's `minify` and `targets`.
582
596
  const { drafts, nonStandard, errorRecovery } = this.#parseOptions.lightningcss;
583
597
  const text = (rules) => print([rules], [], { drafts, nonStandard, errorRecovery }, false).code;
584
- const code = (record.printed ??= text(rule));
598
+ const code = (record.printed ??= this.#printedStack(record, rule, text));
585
599
  const open = code.indexOf(' {\n');
586
600
  return {
587
601
  className: record.className,
@@ -595,7 +609,9 @@ export class Compiler {
595
609
  declaration: code
596
610
  .slice(open + 3, code.lastIndexOf('\n}'))
597
611
  .trim()
598
- .replace(/;$/, ''),
612
+ .replace(/;$/, '')
613
+ // Fallbacks, on one line.
614
+ .replaceAll(/;\n\s*/g, '; '),
599
615
  property: this.#propertyName(atom.property),
600
616
  covers: Object.hasOwn(SHORTHANDS, atom.property) ? SHORTHANDS[atom.property] : [],
601
617
  important: atom.declaration.important,
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { key, overrideKey, parseRules, valueOf, walk } from './block.js';
2
+ import { atomDeclaration, key, overrideKey, parseRules, valueOf, walk } from './block.js';
3
3
  import { VARIABLE_PREFIX } from './variables.js';
4
4
  // With `customPropertyLocalization`, a custom property that a block or
5
5
  // stylesheet file declares gets a generated name, used only in that block or
@@ -103,7 +103,7 @@ export function localizeCustomProperties(atoms, globals, scope, lang, options) {
103
103
  local.set(name, VARIABLE_PREFIX + hash);
104
104
  };
105
105
  for (const atom of atoms)
106
- eachDeclared(atom.declaration.value, declared);
106
+ eachDeclared(atom.declaration.values, declared);
107
107
  const own = (global) => !shared.keys.has(global.key);
108
108
  for (const global of globals)
109
109
  if (own(global))
@@ -125,23 +125,19 @@ export function localizeCustomProperties(atoms, globals, scope, lang, options) {
125
125
  };
126
126
  }),
127
127
  atoms: (list) => list.map((atom) => {
128
- const value = rename(atom.declaration.value, local);
128
+ const values = rename(atom.declaration.values, local);
129
129
  const atRules = atom.atRules.map((segment) => {
130
130
  const rule = rename(segment.rule, local);
131
131
  return rule === segment.rule ? segment : { key: key(valueKey(rule)), rule };
132
132
  });
133
- const unchanged = value === atom.declaration.value && atRules.every((s, i) => s === atom.atRules[i]);
133
+ const unchanged = values === atom.declaration.values && atRules.every((s, i) => s === atom.atRules[i]);
134
134
  if (unchanged)
135
135
  return atom;
136
136
  return {
137
137
  ...atom,
138
138
  atRules,
139
139
  property: local.get(atom.property) ?? atom.property,
140
- declaration: {
141
- ...atom.declaration,
142
- key: key(value) + (atom.declaration.important ? '!' : ''),
143
- value,
144
- },
140
+ declaration: atomDeclaration(values, atom.declaration.important),
145
141
  };
146
142
  }),
147
143
  };
@@ -16,7 +16,8 @@ const NO_VARIABLES = /^@(font-face|property|page|counter-style|font-feature-valu
16
16
  const SASS_AT_RULES = /^@(include|return|if|else|each|for|while|debug|warn|error|function|mixin)\b/;
17
17
  const POSITION_MESSAGE = "`${…}` can only be used in a declaration's value, because it becomes a `var()`, which isn't valid in selectors, property names, or at-rule conditions. To share media queries, use Sass variables.";
18
18
  const NO_VARIABLES_MESSAGE = "`${…}` can't be used here: this at-rule doesn't accept `var()`, or has no element for the runtime to set it on.";
19
- const QUOTED_MESSAGE = '`${…}` can\'t be used inside a quoted string or `url()`, because it becomes a `var()`, which doesn\'t work there. Pass the whole value instead: `content: ${JSON.stringify(label)}`, or `--image: ${`url("${path}")`}` with `background-image: var(--image)`.';
19
+ const UNQUOTED_URL_MESSAGE = '`${…}` can\'t be used in an unquoted `url()`. Add quotes: `url("${path}")`.';
20
+ const SASS_MESSAGE = "`#{…}` can't be used in the same value as `${…}`, because that value is set at runtime, after Sass has run. Use a Sass variable in a separate declaration.";
20
21
  const TYPE_RUNTIME_MESSAGE = "`type()` and its default are written to the CSS file, so they can't use `${…}`.";
21
22
  const TYPE_DEFAULT_MESSAGE = '`type()` needs a default, because a typed `@property` requires an `initial-value`: `var(${w}px type(<length>), 120px)`.';
22
23
  const FUNCTION_MESSAGE = "`${…}` can't be used as a function's name.";
@@ -50,11 +51,26 @@ export function interpolate(strings, expressions, lang) {
50
51
  // Adds a slot for the text at [start, end), and returns its placeholder's
51
52
  // name, and the newlines to write after it, so later lines stay in place.
52
53
  const addSlot = (start, end) => {
54
+ const span = text.slice(start, end);
55
+ if (lang === 'scss' && span.includes('#{'))
56
+ fail(SASS_MESSAGE, start);
53
57
  const first = expressionIndex(start);
54
- const parts = text.slice(start, end).split(EXPRESSION);
55
- const used = Array.from({ length: parts.length - 1 }, (_, i) => first + i);
58
+ const parts = span.split(EXPRESSION);
59
+ const used = [];
60
+ let quote = null;
61
+ for (let i = 0; i < span.length; i++) {
62
+ const ch = span[i];
63
+ if (ch === '\\')
64
+ i++;
65
+ else if (ch === EXPRESSION) {
66
+ const index = first + used.length;
67
+ used.push(quote === null ? index : ~index);
68
+ }
69
+ else if (quote === null ? ch === '"' || ch === "'" : ch === quote)
70
+ quote = quote === null ? ch : null;
71
+ }
56
72
  const index = slots.push({ strings: parts, expressions: used }) - 1;
57
- const lines = newlines(text.slice(start, end)) + used.reduce((n, e) => n + newlines(expressions[e]), 0);
73
+ const lines = newlines(span) + used.reduce((n, e) => n + newlines(expressions[e < 0 ? ~e : e]), 0);
58
74
  return { name: `${SLOT_PREFIX}${index}`, lines: '\n'.repeat(lines) };
59
75
  };
60
76
  // What's open: parentheses and Sass's `#{…}`, which don't end statements,
@@ -62,15 +78,14 @@ export function interpolate(strings, expressions, lang) {
62
78
  const open = [];
63
79
  const atStatementLevel = () => open.length === 0 || open.at(-1).type === '{';
64
80
  let statementStart = 0;
65
- // The end of the string starting at `start`, an error if it has an
66
- // expression in it.
81
+ // Where the last `#{…}` ended, since it can't touch a slot.
82
+ let sassEnd = -1;
83
+ // The end of the string starting at `start`.
67
84
  const stringEnd = (start) => {
68
85
  let i = start + 1;
69
86
  for (; i < text.length && text[i] !== text[start]; i++) {
70
87
  if (text[i] === '\\')
71
88
  i++;
72
- else if (text[i] === EXPRESSION)
73
- fail(QUOTED_MESSAGE, i);
74
89
  }
75
90
  return i + 1;
76
91
  };
@@ -172,25 +187,43 @@ export function interpolate(strings, expressions, lang) {
172
187
  }
173
188
  if (ch === '"' || ch === "'") {
174
189
  const end = stringEnd(i);
175
- code += text.slice(i, end);
190
+ const string = text.slice(i, end);
191
+ // `"Count: ${i}"`: the whole string is the value, since a `var()` can't
192
+ // go inside one.
193
+ if (string.includes(EXPRESSION)) {
194
+ checkPosition(i, end);
195
+ const slot = addSlot(i, end);
196
+ code += `var(${slot.name})${slot.lines}`;
197
+ }
198
+ else
199
+ code += string;
176
200
  i = end;
177
201
  tokenStart = i;
178
202
  tokenCode = code.length;
179
203
  continue;
180
204
  }
181
205
  const standsAlone = i === 0 || !IDENT.test(text[i - 1]);
182
- // An unquoted `url()` is one token, which can't hold a `var()`.
206
+ // A `url()` can't hold a `var()`, so it's a value as a whole.
183
207
  if (standsAlone && text.slice(i, i + 4).toLowerCase() === 'url(') {
184
208
  const argument = /\s*/y;
185
209
  argument.lastIndex = i + 4;
186
- const first = text[i + 4 + argument.exec(text)[0].length];
187
- if (first !== '"' && first !== "'") {
188
- const close = text.indexOf(')', i);
189
- const end = close === -1 ? text.length : close + 1;
190
- const inside = text.indexOf(EXPRESSION, i);
191
- if (inside !== -1 && inside < end)
192
- fail(QUOTED_MESSAGE, inside);
193
- code += text.slice(i, end);
210
+ const firstAt = i + 4 + argument.exec(text)[0].length;
211
+ const first = text[firstAt];
212
+ const quoted = first === '"' || first === "'";
213
+ const close = text.indexOf(')', quoted ? stringEnd(firstAt) : i);
214
+ const end = close === -1 ? text.length : close + 1;
215
+ const inside = text.slice(i, end).indexOf(EXPRESSION);
216
+ if (inside !== -1 && !quoted)
217
+ fail(UNQUOTED_URL_MESSAGE, i + inside);
218
+ if (inside !== -1 || !quoted) {
219
+ // `url("${path}")`: the whole `url()` is the value.
220
+ if (inside !== -1) {
221
+ checkPosition(i, end);
222
+ const slot = addSlot(i, end);
223
+ code += `var(${slot.name})${slot.lines}`;
224
+ }
225
+ else
226
+ code += text.slice(i, end);
194
227
  i = end;
195
228
  tokenStart = i;
196
229
  tokenCode = code.length;
@@ -237,6 +270,8 @@ export function interpolate(strings, expressions, lang) {
237
270
  end++;
238
271
  if (text[end] === '(')
239
272
  fail(FUNCTION_MESSAGE, i);
273
+ if (tokenStart === sassEnd || (text[end - 1] === '#' && text[end] === '{'))
274
+ fail(SASS_MESSAGE, i);
240
275
  checkPosition(tokenStart, end);
241
276
  const slot = addSlot(tokenStart, end);
242
277
  code = `${code.slice(0, tokenCode)}var(${slot.name})${slot.lines}`;
@@ -264,9 +299,10 @@ export function interpolate(strings, expressions, lang) {
264
299
  }
265
300
  }
266
301
  else if (ch === '}') {
267
- if (open.length > 0)
268
- open.pop();
269
- if (atStatementLevel())
302
+ // Sass's `#{…}` is part of a value, so it doesn't end a statement.
303
+ if (open.pop()?.type === '#{')
304
+ sassEnd = i + 1;
305
+ else if (atStatementLevel())
270
306
  statementStart = i + 1;
271
307
  }
272
308
  else if (ch === ';' && atStatementLevel())
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { key, overrideKey, valueOf } from './block.js';
2
+ import { atomDeclaration, key, overrideKey, valueOf } from './block.js';
3
3
  // With `keyframeLocalization`, each `@keyframes` name is replaced with a
4
4
  // generated one, like styled-components' `keyframes`. In parsed rules, a
5
5
  // local name is an ident with this prefix and a hash of every definition of
@@ -55,17 +55,10 @@ export function localizeKeyframes(globals) {
55
55
  atoms: (atoms) => atoms.map((atom) => {
56
56
  if (!ANIMATION_PROPERTIES.has(atom.property.replace(/^-\w+-/, '')))
57
57
  return atom;
58
- const value = renameIdents(atom.declaration.value, local);
59
- if (value === atom.declaration.value)
58
+ const values = renameIdents(atom.declaration.values, local);
59
+ if (values === atom.declaration.values)
60
60
  return atom;
61
- return {
62
- ...atom,
63
- declaration: {
64
- ...atom.declaration,
65
- key: key(value) + (atom.declaration.important ? '!' : ''),
66
- value,
67
- },
68
- };
61
+ return { ...atom, declaration: atomDeclaration(values, atom.declaration.important) };
69
62
  }),
70
63
  };
71
64
  }
@@ -1,6 +1,6 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { atRuleInfo } from './at-rules.js';
3
- import { addDeclarations, addGlobal, dropOverridden, key, parseRules, PreStyleError, valueOf, walk, } from './block.js';
3
+ import { addDeclarations, addGlobal, dropOverridden, pruneFallbacks, key, parseRules, PreStyleError, valueOf, walk, } from './block.js';
4
4
  import { localizeCustomProperties } from './custom-properties.js';
5
5
  import { localizeKeyframes } from './keyframes.js';
6
6
  import { compose, isElement } from './selectors.js';
@@ -145,7 +145,7 @@ export function parseStylesheet(code, lang, file, id, options) {
145
145
  }
146
146
  }
147
147
  for (const target of classes.values())
148
- target.atoms = dropOverridden(target.atoms);
148
+ target.atoms = dropOverridden(pruneFallbacks(target.atoms, options.lightningcss));
149
149
  // Custom properties are local to the whole file, like keyframes.
150
150
  if (options.customPropertyLocalization) {
151
151
  const all = [...classes.values()].flatMap((target) => target.atoms);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { transform } from 'lightningcss';
3
- import { key, overrideKey, PreStyleError, valueOf } from './block.js';
3
+ import { atomDeclaration, key, overrideKey, PreStyleError, valueOf } from './block.js';
4
4
  import { atRuleInfo } from './at-rules.js';
5
5
  import { SLOT_PREFIX } from './interpolate.js';
6
6
  import { print } from './print.js';
@@ -43,7 +43,9 @@ const IGNORED = (token) => token.type === 'token' && token.value?.type === 'whit
43
43
  function directSlot(atom) {
44
44
  if (atom.atRules.length > 0 || atom.variant !== null || atom.declaration.important)
45
45
  return null;
46
- const declaration = atom.declaration.value;
46
+ if (atom.declaration.values.length !== 1)
47
+ return null;
48
+ const declaration = atom.declaration.values[0];
47
49
  // Lightning CSS also uses `custom` for properties it doesn't parse, such as
48
50
  // `content` with a `var()`.
49
51
  if (declaration.property !== 'custom' || !atom.property.startsWith('--'))
@@ -251,22 +253,17 @@ export function extractVariables(atoms, globals) {
251
253
  }
252
254
  continue;
253
255
  }
254
- const value = structuredClone(atom.declaration.value);
256
+ const values = structuredClone(atom.declaration.values);
255
257
  const selector = selectorOf(atom);
256
258
  let index = 0;
257
- eachSlot(value, (node) => {
259
+ // Counted across the whole stack, so `height: ${a}; height: ${b}` is two
260
+ // variables.
261
+ eachSlot(values, (node) => {
258
262
  const { slot, syntax } = parseSlot(node);
259
263
  const identity = atomIdentity(atom, index++);
260
264
  useVariable(node, identity, slot, syntax, variables, added, atom.position, selector);
261
265
  });
262
- keptAtoms.push({
263
- ...atom,
264
- declaration: {
265
- ...atom.declaration,
266
- key: key(value) + (atom.declaration.important ? '!' : ''),
267
- value,
268
- },
269
- });
266
+ keptAtoms.push({ ...atom, declaration: atomDeclaration(values, atom.declaration.important) });
270
267
  }
271
268
  return { atoms: keptAtoms, globals: [...kept, ...added.values()], variables };
272
269
  }
package/dist/runtime.js CHANGED
@@ -19,6 +19,8 @@ function hash(value, alphabet) {
19
19
  }
20
20
  return result;
21
21
  }
22
+ // What can't be written as it is in a quoted CSS string.
23
+ const UNSAFE_IN_STRING = /["'\\\n\r\f]/g;
22
24
  // The variable's value, or null when any of its values is null or undefined,
23
25
  // so its default applies. Anything else is almost always a bug, which falling
24
26
  // back would hide.
@@ -26,11 +28,15 @@ function text(strings, indexes, values, invalid) {
26
28
  let result = strings[0];
27
29
  let missing = false;
28
30
  for (let i = 0; i < indexes.length; i++) {
29
- const value = values[indexes[i]];
31
+ const index = indexes[i];
32
+ const value = values[index < 0 ? ~index : index];
30
33
  if (value === null || value === undefined)
31
34
  missing = true;
32
35
  else if (typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)))
33
- result += value + strings[i + 1];
36
+ result +=
37
+ (index < 0
38
+ ? String(value).replace(UNSAFE_IN_STRING, (c) => `\\${c.charCodeAt(0).toString(16)} `)
39
+ : value) + strings[i + 1];
34
40
  else
35
41
  invalid(value, strings);
36
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pre.style/core",
3
- "version": "3.1.0-alpha.3",
3
+ "version": "3.1.0-alpha.5",
4
4
  "description": "Zero-runtime CSS-in-JS that compiles css and scss blocks into atomic CSS classes.",
5
5
  "keywords": [
6
6
  "atomic-css",