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

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:
@@ -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())
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.4",
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",