@pre.style/core 3.1.0-alpha.2 → 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.
@@ -0,0 +1,318 @@
1
+ import { PreStyleError } from './block.js';
2
+ // Each slot is written as this variable, plus its index, until the block is
3
+ // parsed. See `variables.ts`.
4
+ export const SLOT_PREFIX = '--__pre-style-slot-';
5
+ // Stands for an expression while scanning, since it can't be in CSS.
6
+ const EXPRESSION = '\0';
7
+ // Ends a token, so `${w}px` is one slot, and `${a} ${b}` is two.
8
+ const DELIMITER = /[\s,()/;:{}!"']/;
9
+ const IDENT = /[\w-]/;
10
+ // At-rules that can't read a runtime value: their descriptors don't accept
11
+ // `var()`, or, for `@page`, have no element to read it from. Others, such as
12
+ // `@keyframes`, `@position-try`, and `@function`, read it from the element
13
+ // that uses them.
14
+ const NO_VARIABLES = /^@(font-face|property|page|counter-style|font-feature-values|font-palette-values|view-transition)\b/i;
15
+ // Sass at-rules whose arguments are evaluated, rather than written out.
16
+ const SASS_AT_RULES = /^@(include|return|if|else|each|for|while|debug|warn|error|function|mixin)\b/;
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
+ 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 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.";
21
+ const TYPE_RUNTIME_MESSAGE = "`type()` and its default are written to the CSS file, so they can't use `${…}`.";
22
+ const TYPE_DEFAULT_MESSAGE = '`type()` needs a default, because a typed `@property` requires an `initial-value`: `var(${w}px type(<length>), 120px)`.';
23
+ const FUNCTION_MESSAGE = "`${…}` can't be used as a function's name.";
24
+ // The optional type at the end of `var()`'s first argument, as in `attr()`.
25
+ const TYPE = /\s+type\(\s*(.*?)\s*\)\s*$/s;
26
+ const newlines = (text) => text.split('\n').length - 1;
27
+ // Replaces each `${…}` in a block with a placeholder variable. `strings` are
28
+ // the text around the expressions, and `expressions` their source, which is
29
+ // only used for positions, so errors and lines match the file.
30
+ export function interpolate(strings, expressions, lang) {
31
+ const text = strings.join(EXPRESSION);
32
+ const slots = [];
33
+ // The position of the expression at an offset in `text`.
34
+ const positionAt = (offset) => {
35
+ let written = '';
36
+ let index = 0;
37
+ for (let i = 0; i < offset; i++) {
38
+ if (text[i] === EXPRESSION)
39
+ written += `\${${expressions[index++]}}`;
40
+ else
41
+ written += text[i];
42
+ }
43
+ const lastLine = written.lastIndexOf('\n');
44
+ return { line: newlines(written), column: written.length - lastLine };
45
+ };
46
+ const fail = (message, offset) => {
47
+ throw new PreStyleError(message, positionAt(offset));
48
+ };
49
+ // How many expressions come before an offset.
50
+ const expressionIndex = (offset) => text.slice(0, offset).split(EXPRESSION).length - 1;
51
+ // Adds a slot for the text at [start, end), and returns its placeholder's
52
+ // name, and the newlines to write after it, so later lines stay in place.
53
+ const addSlot = (start, end) => {
54
+ const span = text.slice(start, end);
55
+ if (lang === 'scss' && span.includes('#{'))
56
+ fail(SASS_MESSAGE, start);
57
+ const first = expressionIndex(start);
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
+ }
72
+ const index = slots.push({ strings: parts, expressions: used }) - 1;
73
+ const lines = newlines(span) + used.reduce((n, e) => n + newlines(expressions[e < 0 ? ~e : e]), 0);
74
+ return { name: `${SLOT_PREFIX}${index}`, lines: '\n'.repeat(lines) };
75
+ };
76
+ // What's open: parentheses and Sass's `#{…}`, which don't end statements,
77
+ // and blocks, with the text before each.
78
+ const open = [];
79
+ const atStatementLevel = () => open.length === 0 || open.at(-1).type === '{';
80
+ let statementStart = 0;
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`.
84
+ const stringEnd = (start) => {
85
+ let i = start + 1;
86
+ for (; i < text.length && text[i] !== text[start]; i++) {
87
+ if (text[i] === '\\')
88
+ i++;
89
+ }
90
+ return i + 1;
91
+ };
92
+ // What ends the statement at `start`: `;`, `{`, `}`, or nothing.
93
+ const statementEnd = (start) => {
94
+ let depth = 0;
95
+ for (let i = start; i < text.length; i++) {
96
+ const ch = text[i];
97
+ if (ch === '"' || ch === "'")
98
+ i = stringEnd(i) - 1;
99
+ else if (ch === '(')
100
+ depth++;
101
+ else if (ch === ')')
102
+ depth--;
103
+ else if (ch === '#' && text[i + 1] === '{') {
104
+ depth++;
105
+ i++;
106
+ }
107
+ else if (ch === '}' && depth > 0)
108
+ depth--;
109
+ else if (depth === 0 && (ch === ';' || ch === '{' || ch === '}'))
110
+ return ch;
111
+ }
112
+ return '';
113
+ };
114
+ // Checks that a slot starting at `start` and ending at `end` is in a
115
+ // declaration's value.
116
+ const checkPosition = (start, end) => {
117
+ const offset = text.indexOf(EXPRESSION, start);
118
+ for (const { type, header } of open)
119
+ if (type === '{' && NO_VARIABLES.test(header.trim()))
120
+ fail(NO_VARIABLES_MESSAGE, offset);
121
+ const statement = text.slice(statementStart, start).trimStart();
122
+ if (statement.startsWith('@')) {
123
+ if (lang !== 'scss' || !SASS_AT_RULES.test(statement))
124
+ fail(POSITION_MESSAGE, offset);
125
+ return;
126
+ }
127
+ if (!statement.includes(':') || statementEnd(end) === '{')
128
+ fail(POSITION_MESSAGE, offset);
129
+ };
130
+ // The end of `var()`'s first argument, starting at `start`.
131
+ const firstArgumentEnd = (start) => {
132
+ let depth = 0;
133
+ for (let i = start; i < text.length; i++) {
134
+ const ch = text[i];
135
+ if (ch === '"' || ch === "'")
136
+ i = stringEnd(i) - 1;
137
+ else if (ch === '(')
138
+ depth++;
139
+ else if (ch === ')') {
140
+ if (depth === 0)
141
+ return i;
142
+ depth--;
143
+ }
144
+ else if (ch === ',' && depth === 0)
145
+ return i;
146
+ }
147
+ return text.length;
148
+ };
149
+ // The parenthesis that closes the `var()` whose fallback starts at `start`.
150
+ const closingParenthesis = (start) => {
151
+ let end = firstArgumentEnd(start);
152
+ while (text[end] === ',')
153
+ end = firstArgumentEnd(end + 1);
154
+ return end;
155
+ };
156
+ let code = '';
157
+ // Where the current token starts, in `text` and in `code`.
158
+ let tokenStart = 0;
159
+ let tokenCode = 0;
160
+ // Keeps a comment's text, without its expressions, and the lines they took.
161
+ const comment = (start, end) => {
162
+ const first = expressionIndex(start);
163
+ const parts = text.slice(start, end).split(EXPRESSION);
164
+ let lines = '';
165
+ for (let i = 0; i < parts.length - 1; i++)
166
+ lines += '\n'.repeat(newlines(expressions[first + i]));
167
+ return parts.join('') + lines;
168
+ };
169
+ let i = 0;
170
+ while (i < text.length) {
171
+ const ch = text[i];
172
+ if (ch === '/' && text[i + 1] === '*') {
173
+ const close = text.indexOf('*/', i + 2);
174
+ const end = close === -1 ? text.length : close + 2;
175
+ code += comment(i, end);
176
+ i = end;
177
+ tokenStart = i;
178
+ tokenCode = code.length;
179
+ continue;
180
+ }
181
+ if (lang === 'scss' && ch === '/' && text[i + 1] === '/') {
182
+ const newline = text.indexOf('\n', i);
183
+ const end = newline === -1 ? text.length : newline;
184
+ code += comment(i, end);
185
+ i = end;
186
+ continue;
187
+ }
188
+ if (ch === '"' || ch === "'") {
189
+ const end = stringEnd(i);
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;
200
+ i = end;
201
+ tokenStart = i;
202
+ tokenCode = code.length;
203
+ continue;
204
+ }
205
+ const standsAlone = i === 0 || !IDENT.test(text[i - 1]);
206
+ // A `url()` can't hold a `var()`, so it's a value as a whole.
207
+ if (standsAlone && text.slice(i, i + 4).toLowerCase() === 'url(') {
208
+ const argument = /\s*/y;
209
+ argument.lastIndex = i + 4;
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);
227
+ i = end;
228
+ tokenStart = i;
229
+ tokenCode = code.length;
230
+ continue;
231
+ }
232
+ }
233
+ // `var(${w}px, 120px)`: the first argument is one slot, and what follows
234
+ // is an ordinary fallback.
235
+ if (standsAlone && text.slice(i, i + 4).toLowerCase() === 'var(') {
236
+ const end = firstArgumentEnd(i + 4);
237
+ const raw = text.slice(i + 4, end);
238
+ const typed = TYPE.exec(raw);
239
+ if (typed?.[1].includes(EXPRESSION))
240
+ fail(TYPE_RUNTIME_MESSAGE, text.indexOf(EXPRESSION, i));
241
+ const argument = typed === null ? raw : raw.slice(0, typed.index);
242
+ if (argument.includes(EXPRESSION)) {
243
+ const start = i + 4 + (argument.length - argument.trimStart().length);
244
+ const trimmedEnd = i + 4 + argument.trimEnd().length;
245
+ checkPosition(start, end);
246
+ // `type(*)` is the same as no type.
247
+ const syntax = typed === null || typed[1] === '*' ? null : typed[1];
248
+ if (syntax !== null) {
249
+ if (text[end] !== ',')
250
+ fail(TYPE_DEFAULT_MESSAGE, start);
251
+ const close = closingParenthesis(end + 1);
252
+ const inDefault = text.slice(end, close).indexOf(EXPRESSION);
253
+ if (inDefault !== -1)
254
+ fail(TYPE_RUNTIME_MESSAGE, end + inDefault);
255
+ }
256
+ const slot = addSlot(start, trimmedEnd);
257
+ const type = syntax === null ? '' : `-t${Buffer.from(syntax).toString('hex')}`;
258
+ const skipped = '\n'.repeat(newlines(text.slice(trimmedEnd, end)));
259
+ code += `var(${slot.name}${type}${slot.lines}${skipped}`;
260
+ open.push({ type: '(', header: '' });
261
+ i = end;
262
+ tokenStart = i;
263
+ tokenCode = code.length;
264
+ continue;
265
+ }
266
+ }
267
+ if (ch === EXPRESSION) {
268
+ let end = i;
269
+ while (end < text.length && !DELIMITER.test(text[end]))
270
+ end++;
271
+ if (text[end] === '(')
272
+ fail(FUNCTION_MESSAGE, i);
273
+ if (tokenStart === sassEnd || (text[end - 1] === '#' && text[end] === '{'))
274
+ fail(SASS_MESSAGE, i);
275
+ checkPosition(tokenStart, end);
276
+ const slot = addSlot(tokenStart, end);
277
+ code = `${code.slice(0, tokenCode)}var(${slot.name})${slot.lines}`;
278
+ i = end;
279
+ tokenStart = i;
280
+ tokenCode = code.length;
281
+ continue;
282
+ }
283
+ if (ch === '#' && text[i + 1] === '{') {
284
+ open.push({ type: '#{', header: '' });
285
+ code += '#{';
286
+ i += 2;
287
+ continue;
288
+ }
289
+ if (ch === '(')
290
+ open.push({ type: '(', header: '' });
291
+ else if (ch === ')') {
292
+ if (open.at(-1)?.type === '(')
293
+ open.pop();
294
+ }
295
+ else if (ch === '{') {
296
+ if (atStatementLevel()) {
297
+ open.push({ type: '{', header: text.slice(statementStart, i) });
298
+ statementStart = i + 1;
299
+ }
300
+ }
301
+ else if (ch === '}') {
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())
306
+ statementStart = i + 1;
307
+ }
308
+ else if (ch === ';' && atStatementLevel())
309
+ statementStart = i + 1;
310
+ code += ch;
311
+ i++;
312
+ if (DELIMITER.test(ch)) {
313
+ tokenStart = i;
314
+ tokenCode = code.length;
315
+ }
316
+ }
317
+ return { code, slots };
318
+ }
@@ -4,6 +4,7 @@ export interface LibraryConfig {
4
4
  exclude?: string | string[];
5
5
  prependFiles?: PrependFiles;
6
6
  keyframeLocalization?: boolean;
7
+ customPropertyLocalization?: boolean;
7
8
  }
8
9
  export interface ResolvedLibrary {
9
10
  name: string;
@@ -12,5 +13,6 @@ export interface ResolvedLibrary {
12
13
  exclude: string[];
13
14
  prependFiles?: PrependFiles;
14
15
  keyframeLocalization?: boolean;
16
+ customPropertyLocalization?: boolean;
15
17
  }
16
18
  export declare function findLibraries(root: string, overrides?: Record<string, LibraryConfig | boolean>, warn?: (message: string) => void): ResolvedLibrary[];
@@ -3,7 +3,13 @@ import { dirname, join } from 'node:path';
3
3
  import { checkField } from './config.js';
4
4
  // Keys a library's field shares with the app's. `libraries` and
5
5
  // `quirksMode` only apply to the app.
6
- const SHARED_KEYS = new Set(['include', 'exclude', 'prependFiles', 'keyframeLocalization']);
6
+ const SHARED_KEYS = new Set([
7
+ 'include',
8
+ 'exclude',
9
+ 'prependFiles',
10
+ 'keyframeLocalization',
11
+ 'customPropertyLocalization',
12
+ ]);
7
13
  const APP_KEYS = new Set(['libraries', 'quirksMode']);
8
14
  const DEFAULT_INCLUDE = ['**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,astro}'];
9
15
  const readPackage = (dir) => JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
@@ -44,7 +50,7 @@ export function findLibraries(root, overrides = {}, warn = () => { }) {
44
50
  for (const warning of checkField(config, where, new Set([...SHARED_KEYS, ...APP_KEYS])))
45
51
  warn(warning);
46
52
  }
47
- const { include, exclude, prependFiles, keyframeLocalization } = config === true ? {} : config;
53
+ const { include, exclude, prependFiles, keyframeLocalization, customPropertyLocalization } = config === true ? {} : config;
48
54
  libraries.push({
49
55
  name,
50
56
  dir,
@@ -52,6 +58,7 @@ export function findLibraries(root, overrides = {}, warn = () => { }) {
52
58
  exclude: exclude === undefined ? [] : [exclude].flat(),
53
59
  prependFiles,
54
60
  keyframeLocalization,
61
+ customPropertyLocalization,
55
62
  });
56
63
  // A library's peer dependencies are installed by the app, and found the
57
64
  // same way from the library.
@@ -29,13 +29,15 @@ async function openProject(params) {
29
29
  const compiler = createCompiler({
30
30
  root: app.dir,
31
31
  prependFiles: app.config.prependFiles,
32
- libraries: libraries.map(({ dir, prependFiles, keyframeLocalization }) => ({
32
+ libraries: libraries.map(({ dir, prependFiles, keyframeLocalization, customPropertyLocalization }) => ({
33
33
  dir,
34
34
  prependFiles,
35
35
  keyframeLocalization,
36
+ customPropertyLocalization,
36
37
  })),
37
38
  quirksMode: app.config.quirksMode,
38
39
  keyframeLocalization: app.config.keyframeLocalization,
40
+ customPropertyLocalization: app.config.customPropertyLocalization,
39
41
  warn: () => { },
40
42
  });
41
43
  projects.set(params.projectHandle, compiler);
@@ -1,6 +1,7 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { atRuleInfo } from './at-rules.js';
3
3
  import { addDeclarations, addGlobal, dropOverridden, key, parseRules, PreStyleError, valueOf, walk, } from './block.js';
4
+ import { localizeCustomProperties } from './custom-properties.js';
4
5
  import { localizeKeyframes } from './keyframes.js';
5
6
  import { compose, isElement } from './selectors.js';
6
7
  // Classes used as context for another class (`.card:hover .title`), or in a
@@ -145,6 +146,14 @@ export function parseStylesheet(code, lang, file, id, options) {
145
146
  }
146
147
  for (const target of classes.values())
147
148
  target.atoms = dropOverridden(target.atoms);
149
+ // Custom properties are local to the whole file, like keyframes.
150
+ if (options.customPropertyLocalization) {
151
+ const all = [...classes.values()].flatMap((target) => target.atoms);
152
+ const local = localizeCustomProperties(all, parsed.globals, `file\n${id}`, lang, options);
153
+ parsed.globals = local.globals;
154
+ for (const target of classes.values())
155
+ target.atoms = local.atoms(target.atoms);
156
+ }
148
157
  // Keyframes are local to the whole file, as in CSS Modules.
149
158
  if (options.keyframeLocalization) {
150
159
  const local = localizeKeyframes(parsed.globals);
@@ -0,0 +1,13 @@
1
+ import type { Atom, GlobalRule } from './block.ts';
2
+ export declare const VARIABLE_PREFIX = "--\0pre.style-variable:";
3
+ export interface Variable {
4
+ key: string;
5
+ property: string | null;
6
+ slot: number;
7
+ selector: string[] | null;
8
+ }
9
+ export declare function extractVariables(atoms: Atom[], globals: GlobalRule[]): {
10
+ atoms: Atom[];
11
+ globals: GlobalRule[];
12
+ variables: Variable[];
13
+ };