@symbiote-native/css-parser 0.3.0 → 0.5.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.
- package/README.md +33 -31
- package/build/generate-dts/index.js +3 -8
- package/build/generate-dts-cli.js +5 -1
- package/build/golden-corpus/fixtures/ScopedGlobalDemo.svelte +25 -0
- package/build/golden-corpus/fixtures/ScopedGlobalDemo.svelte.d.ts +3 -0
- package/build/index.d.ts +8 -7
- package/build/index.js +4 -4
- package/build/lightning/declarations.d.ts +46 -0
- package/build/lightning/declarations.js +885 -0
- package/build/lightning/rules.d.ts +52 -0
- package/build/lightning/rules.js +157 -0
- package/build/lightning/selectors.d.ts +23 -0
- package/build/lightning/selectors.js +468 -0
- package/build/metro-css-module/index.d.ts +30 -2
- package/build/metro-css-module/index.js +142 -36
- package/build/preprocessors/index.js +4 -3
- package/build/properties.d.ts +5 -6
- package/build/properties.js +26 -14
- package/build/scoped-classes.d.ts +23 -0
- package/build/scoped-classes.js +35 -0
- package/build/values.d.ts +3 -7
- package/build/values.js +3 -60
- package/package.json +13 -3
- package/typescript-plugin.cjs +21 -14
- package/build/global-selectors.d.ts +0 -25
- package/build/global-selectors.js +0 -98
- package/build/parser/index.d.ts +0 -59
- package/build/parser/index.js +0 -338
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Selector reading for the lightningcss pipeline: an AST walk that replaced the retired
|
|
2
|
+
// text-based `extractClassName` / `extractClassTokens` pair.
|
|
3
|
+
//
|
|
4
|
+
// That pair derived a registry key from the SELECTOR TEXT — it camelCased each class, collapsed
|
|
5
|
+
// every class in the selector into one concatenated key, and returned `null` for whatever its
|
|
6
|
+
// regexes failed to recognize. Each of those steps is lossy in a way that maps two different
|
|
7
|
+
// rules onto ONE key, and the later rule then overwrites the earlier per property, silently:
|
|
8
|
+
//
|
|
9
|
+
// .card-title{…} .cardTitle{…} -> both key `cardTitle` the first rule is GONE
|
|
10
|
+
// .card:hover{…} .card{…} -> both key `card` the :hover rule OVERWRITES the base
|
|
11
|
+
// .card[data-x]{…} -> key `card[dataX]` a key no element can ever carry
|
|
12
|
+
// .a.b / .a .b / .a>.b / .a+.b -> all key `aB` five selectors, one key, merged
|
|
13
|
+
//
|
|
14
|
+
// (Traps six and seven in `.claude/rules/style-registry-collisions.md`, measured 2026-08-20.)
|
|
15
|
+
//
|
|
16
|
+
// So this module reports what the selector ACTUALLY says and refuses to guess:
|
|
17
|
+
// - tokens stay AS AUTHORED — `card-title` is `card-title`. Casing is the caller's problem, and
|
|
18
|
+
// a caller that never re-cases can never collide two spellings onto one key.
|
|
19
|
+
// - the class list stays a LIST, with the combinator of every gap beside it, so a descendant
|
|
20
|
+
// rule is distinguishable from a compound one instead of both flattening to a concatenation.
|
|
21
|
+
// - a selector RN can never match (pseudo-class/-element, attribute, element, id, universal) is
|
|
22
|
+
// dropped WITH a reason and a warning, rather than silently degrading into a key that either
|
|
23
|
+
// collides with a real rule or matches nothing at all.
|
|
24
|
+
//
|
|
25
|
+
// The caller must pass `nonStandard: { deepSelectorCombinator: true }` to lightningcss's
|
|
26
|
+
// `transform`/`bundle` for a `deep` combinator to ever appear: without it `>>>` and `/deep/` are
|
|
27
|
+
// rejected at parse time as an invalid dangling combinator and the whole rule is lost before this
|
|
28
|
+
// walk sees it. That flag covers ONLY those two spellings — the other three deep forms are not
|
|
29
|
+
// lightningcss features at all and need no flag, so do not go re-reading its docs looking for
|
|
30
|
+
// them: `::v-deep` / `::ng-deep` arrive as an ordinary custom pseudo-element between two
|
|
31
|
+
// descendant combinators, and Vue's `:deep(X)` as a custom-function pseudo-class whose payload is
|
|
32
|
+
// a raw token stream — structurally the same thing `:global(X)` is under that same mode. All three
|
|
33
|
+
// are folded into `deep` here.
|
|
34
|
+
//
|
|
35
|
+
// `:global(X)` arrives in TWO DIFFERENT SHAPES and BOTH are live, so both are handled (measured
|
|
36
|
+
// 2026-08-20, lightningcss 1.32, same CSS through both modes):
|
|
37
|
+
//
|
|
38
|
+
// cssModules OFF {kind:'custom-function', name:'global', arguments:[…raw token stream…]}
|
|
39
|
+
// cssModules ON {kind:'global', selector:[…parsed SelectorComponent[]…]}
|
|
40
|
+
//
|
|
41
|
+
// A `.module.css` / scoped file runs WITH cssModules and gets the parsed form; the plain-`.css`
|
|
42
|
+
// pipeline runs WITHOUT it and gets the token stream. Handling only one of them silently drops the
|
|
43
|
+
// whole rule on the other — which is exactly how `:global(.reset)` in a `.module.css` came to
|
|
44
|
+
// contribute no rule and no export. `:deep()` does NOT have this split: lightningcss implements no
|
|
45
|
+
// `:deep()` at all, so it is the raw custom-function form under BOTH modes.
|
|
46
|
+
// Why the rule can never fire on a device, per reason — the half of the warning that tells an
|
|
47
|
+
// author what to do instead of just what was thrown away.
|
|
48
|
+
const DROP_EXPLANATION = {
|
|
49
|
+
root: 'a `:root` rule paints nothing — it exists to declare custom properties, which are collected by their own pass',
|
|
50
|
+
'pseudo-class': 'React Native has no pseudo-class state (no hover/focus/nth-child)',
|
|
51
|
+
'pseudo-element': 'React Native has no pseudo-elements',
|
|
52
|
+
attribute: 'React Native has no attribute selectors',
|
|
53
|
+
element: 'React Native has no element/tag selectors',
|
|
54
|
+
id: 'React Native has no id selectors',
|
|
55
|
+
universal: 'React Native has no universal selector',
|
|
56
|
+
unsupported: 'this selector shape has no React Native equivalent',
|
|
57
|
+
// Never reaches the shared "can never match" sentence — it has its own branch at the warn site.
|
|
58
|
+
'state-pseudo-class': '',
|
|
59
|
+
};
|
|
60
|
+
// `:active` support is OFF, and the selector machinery below is kept intact so one line turns it
|
|
61
|
+
// back on.
|
|
62
|
+
//
|
|
63
|
+
// WHY. The pressed look has a second, better route that did not exist when `:active` landed: a
|
|
64
|
+
// functional `style={({pressed}) => …}`, which every lowering transform specialises into
|
|
65
|
+
// `style` + `activeStyle` at build time (2026-08-23). It reaches the same slot with no pseudo-class
|
|
66
|
+
// machinery, it is what the ecosystem already writes, and it lowers — so the reason `:active`
|
|
67
|
+
// existed, keeping a Pressable lowerable without a state-reading callback, is gone.
|
|
68
|
+
//
|
|
69
|
+
// Keeping BOTH live is what argues against it: they occupy different cascade slots (`activeStyle`
|
|
70
|
+
// replaces the authored style, an `:active` class rule replaces the class style), so an adapter has
|
|
71
|
+
// two ways to say one thing and a debugging session has two places to look. This is also part of a
|
|
72
|
+
// larger pseudo-class-state feature that is not built; shipping a fragment of it invites code that
|
|
73
|
+
// depends on the fragment.
|
|
74
|
+
//
|
|
75
|
+
// The engine side is deliberately untouched: with no `:active` rule ever registered,
|
|
76
|
+
// `hasActiveRules` stays false and `resolveActiveClassName` is never called, so the path costs
|
|
77
|
+
// nothing while it waits.
|
|
78
|
+
// Typed `boolean`, not inferred as the literal `false`: the literal would let tsc prune the
|
|
79
|
+
// enabled branch of `selectors.test.ts` as unreachable, and that branch is the recorded contract
|
|
80
|
+
// the flag restores — pruning it is how the dormant half rots unnoticed.
|
|
81
|
+
export const IS_STATE_TOKEN_ENABLED = false;
|
|
82
|
+
const DEEP_PSEUDO_ELEMENTS = new Set(['v-deep', 'ng-deep']);
|
|
83
|
+
// Every `SelectorComponent['type']`, so the guard below rejects a shape lightningcss does not
|
|
84
|
+
// produce instead of trusting any object that happens to carry a `type` string.
|
|
85
|
+
const SELECTOR_COMPONENT_TYPES = new Set([
|
|
86
|
+
'combinator',
|
|
87
|
+
'universal',
|
|
88
|
+
'namespace',
|
|
89
|
+
'type',
|
|
90
|
+
'id',
|
|
91
|
+
'class',
|
|
92
|
+
'attribute',
|
|
93
|
+
'pseudo-class',
|
|
94
|
+
'pseudo-element',
|
|
95
|
+
'nesting',
|
|
96
|
+
]);
|
|
97
|
+
function isRecord(value) {
|
|
98
|
+
return typeof value === 'object' && value !== null;
|
|
99
|
+
}
|
|
100
|
+
function isSelectorComponent(value) {
|
|
101
|
+
return (isRecord(value) &&
|
|
102
|
+
typeof value.type === 'string' &&
|
|
103
|
+
SELECTOR_COMPONENT_TYPES.has(value.type));
|
|
104
|
+
}
|
|
105
|
+
function isTokenOrValue(value) {
|
|
106
|
+
return isRecord(value) && typeof value.type === 'string';
|
|
107
|
+
}
|
|
108
|
+
/** The subset of lightningcss combinators this pipeline can act on; the rest drop the rule. */
|
|
109
|
+
function combinatorFor(value) {
|
|
110
|
+
switch (value) {
|
|
111
|
+
case 'descendant':
|
|
112
|
+
case 'child':
|
|
113
|
+
case 'next-sibling':
|
|
114
|
+
case 'later-sibling':
|
|
115
|
+
return value;
|
|
116
|
+
// `>>>` and `/deep/` differ only in spelling; a caller that treats them apart would be
|
|
117
|
+
// encoding CSS trivia, not a matching rule.
|
|
118
|
+
case 'deep-descendant':
|
|
119
|
+
case 'deep':
|
|
120
|
+
return 'deep';
|
|
121
|
+
default:
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// lightningcss reports a plain state pseudo-class by name in `kind`. The TOKEN keeps the colon so
|
|
126
|
+
// it stays unspellable as a class name.
|
|
127
|
+
const STATE_PSEUDO_CLASS = 'active';
|
|
128
|
+
export const STATE_TOKEN = ':active';
|
|
129
|
+
function createBuilder() {
|
|
130
|
+
return {
|
|
131
|
+
tokens: [],
|
|
132
|
+
combinators: [],
|
|
133
|
+
specificity: [0, 0, 0],
|
|
134
|
+
pending: 'none',
|
|
135
|
+
drop: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function pushToken(builder, name) {
|
|
139
|
+
if (builder.tokens.length > 0)
|
|
140
|
+
builder.combinators.push(builder.pending);
|
|
141
|
+
builder.tokens.push(name);
|
|
142
|
+
builder.pending = 'none';
|
|
143
|
+
}
|
|
144
|
+
function setCombinator(builder, next) {
|
|
145
|
+
// A descendant never downgrades an explicit combinator already pending. lightningcss brackets
|
|
146
|
+
// `::v-deep` with a synthetic descendant on BOTH sides, and inside a `:global()` token stream
|
|
147
|
+
// `>` arrives surrounded by whitespace — in either case the second descendant would otherwise
|
|
148
|
+
// erase the real combinator the author wrote.
|
|
149
|
+
if (next === 'descendant' && builder.pending !== 'none')
|
|
150
|
+
return;
|
|
151
|
+
builder.pending = next;
|
|
152
|
+
}
|
|
153
|
+
function drop(builder, reason, detail) {
|
|
154
|
+
builder.drop ??= { reason, detail };
|
|
155
|
+
}
|
|
156
|
+
//#region custom-function payload token streams
|
|
157
|
+
/**
|
|
158
|
+
* Fold the payload of a custom-function pseudo-class into the builder as if its classes had been
|
|
159
|
+
* written bare. Serves both `:global(X)` and `:deep(X)` — lightningcss implements neither, so both
|
|
160
|
+
* arrive in the identical shape and one unwrapper covers them.
|
|
161
|
+
*
|
|
162
|
+
* This is the RAW-TOKEN half of `:global()`, which is the shape a PLAIN `.css` file produces —
|
|
163
|
+
* it is not run through cssModules, so `:global()` stays a `custom-function` pseudo-class whose
|
|
164
|
+
* arguments are an unparsed token stream (measured, lightningcss 1.32): `:global(.a > .b)` arrives
|
|
165
|
+
* as delim `.` · ident `a` · white-space · delim `>` · white-space · delim `.` · ident `b`.
|
|
166
|
+
* With cssModules ON the same source arrives as `kind:'global'` carrying a real parsed selector,
|
|
167
|
+
* handled where that kind is matched — `:global()` never disappears, it changes shape, and
|
|
168
|
+
* assuming the mode erased it is what silently killed the rule in every `.module.*` file.
|
|
169
|
+
*
|
|
170
|
+
* Neither wrapper changes which classes an element must carry — `:global()` only says a name lives
|
|
171
|
+
* outside the file's scope, `:deep()` only says the match may cross a scope boundary. So the
|
|
172
|
+
* payload participates exactly as if unwrapped, the rule `stripGlobalWrappers` follows in the text
|
|
173
|
+
* parser. `wrapper` is the authored spelling, used only so a drop warning names the right one.
|
|
174
|
+
*/
|
|
175
|
+
function consumePayload(builder, args, wrapper) {
|
|
176
|
+
for (let index = 0; index < args.length; index++) {
|
|
177
|
+
const argument = args[index];
|
|
178
|
+
if (!isTokenOrValue(argument) || argument.type !== 'token') {
|
|
179
|
+
drop(builder, 'unsupported', wrapper);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const token = argument.value;
|
|
183
|
+
switch (token.type) {
|
|
184
|
+
case 'white-space':
|
|
185
|
+
setCombinator(builder, 'descendant');
|
|
186
|
+
break;
|
|
187
|
+
case 'delim': {
|
|
188
|
+
if (token.value === '>') {
|
|
189
|
+
setCombinator(builder, 'child');
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (token.value === '+') {
|
|
193
|
+
setCombinator(builder, 'next-sibling');
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (token.value === '~') {
|
|
197
|
+
setCombinator(builder, 'later-sibling');
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (token.value === '*') {
|
|
201
|
+
drop(builder, 'universal', '*');
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
if (token.value !== '.') {
|
|
205
|
+
drop(builder, 'unsupported', `${wrapper.slice(0, -1)}${token.value})`);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
// A class is two tokens — delim `.` then the ident. A `.` with no ident behind it means
|
|
209
|
+
// the payload is malformed, and nothing about it is then trustworthy.
|
|
210
|
+
const name = identAt(args, index + 1);
|
|
211
|
+
if (name === null) {
|
|
212
|
+
drop(builder, 'unsupported', wrapper);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
builder.specificity[1]++;
|
|
216
|
+
pushToken(builder, name);
|
|
217
|
+
index++;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 'colon': {
|
|
221
|
+
// One colon is a pseudo-class, two a pseudo-element; either way the name is the ident
|
|
222
|
+
// after them, and reporting it is the difference between a warning an author can act on
|
|
223
|
+
// and one that only says ":global()" / ":deep()".
|
|
224
|
+
const isPseudoElement = isColonAt(args, index + 1);
|
|
225
|
+
const nameIndex = index + (isPseudoElement ? 2 : 1);
|
|
226
|
+
const name = identAt(args, nameIndex) ?? wrapper;
|
|
227
|
+
builder.specificity[isPseudoElement ? 2 : 1]++;
|
|
228
|
+
// `:active` is kept on this path too, or the SAME CSS behaves differently on the
|
|
229
|
+
// cssModules flag — the recurring hazard this file's header names. `:global(.btn:active)`
|
|
230
|
+
// arrives parsed (`kind:'global'`) with cssModules ON and reaches the ordinary walk, which
|
|
231
|
+
// keeps it; with the flag OFF it arrives here as a raw token stream and would drop the
|
|
232
|
+
// WHOLE rule. Same source, opposite outcome, decided by which file it lives in.
|
|
233
|
+
//
|
|
234
|
+
// Keeping it is also the right side of the `:deep` asymmetry rather than an exception to
|
|
235
|
+
// it: `:global()` says only that the NAME lives outside this file's scope, while
|
|
236
|
+
// `:deep()` says the MATCH may cross a scope boundary. Only the second breaks the promise
|
|
237
|
+
// the state token rests on — that the rule targets the node whose press machine owns the
|
|
238
|
+
// state. Svelte cares most, since `:global()` is its ONLY escape hatch.
|
|
239
|
+
if (!isPseudoElement && name === STATE_PSEUDO_CLASS) {
|
|
240
|
+
if (builder.combinators.includes('deep') ||
|
|
241
|
+
builder.pending === 'deep') {
|
|
242
|
+
drop(builder, 'pseudo-class', 'active through a deep combinator');
|
|
243
|
+
}
|
|
244
|
+
else if (IS_STATE_TOKEN_ENABLED) {
|
|
245
|
+
pushToken(builder, STATE_TOKEN);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
drop(builder, 'state-pseudo-class', 'active');
|
|
249
|
+
}
|
|
250
|
+
index = nameIndex;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
drop(builder, isPseudoElement ? 'pseudo-element' : 'pseudo-class', name);
|
|
254
|
+
index = nameIndex;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case 'ident':
|
|
258
|
+
builder.specificity[2]++;
|
|
259
|
+
drop(builder, 'element', token.value);
|
|
260
|
+
break;
|
|
261
|
+
case 'id-hash':
|
|
262
|
+
builder.specificity[0]++;
|
|
263
|
+
drop(builder, 'id', `#${token.value}`);
|
|
264
|
+
break;
|
|
265
|
+
case 'square-bracket-block': {
|
|
266
|
+
// The parsed shape reports `[data-x]` by name, so the token stream reads the name too —
|
|
267
|
+
// otherwise the same CSS would warn differently depending on the cssModules flag. The
|
|
268
|
+
// block's remaining tokens are left to fall through: they can only add a second drop, and
|
|
269
|
+
// the first one already recorded is the one reported.
|
|
270
|
+
const name = identAt(args, index + 1);
|
|
271
|
+
builder.specificity[1]++;
|
|
272
|
+
drop(builder, 'attribute', name === null ? '[…]' : `[${name}]`);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
default:
|
|
276
|
+
drop(builder, 'unsupported', wrapper);
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function identAt(args, index) {
|
|
282
|
+
const argument = args[index];
|
|
283
|
+
if (!isTokenOrValue(argument) || argument.type !== 'token')
|
|
284
|
+
return null;
|
|
285
|
+
return argument.value.type === 'ident' ? argument.value.value : null;
|
|
286
|
+
}
|
|
287
|
+
function isColonAt(args, index) {
|
|
288
|
+
const argument = args[index];
|
|
289
|
+
return (isTokenOrValue(argument) &&
|
|
290
|
+
argument.type === 'token' &&
|
|
291
|
+
argument.value.type === 'colon');
|
|
292
|
+
}
|
|
293
|
+
//#endregion custom-function payload token streams
|
|
294
|
+
function consumeComponent(builder, component) {
|
|
295
|
+
switch (component.type) {
|
|
296
|
+
case 'class':
|
|
297
|
+
builder.specificity[1]++;
|
|
298
|
+
pushToken(builder, component.name);
|
|
299
|
+
return;
|
|
300
|
+
case 'combinator': {
|
|
301
|
+
const combinator = combinatorFor(component.value);
|
|
302
|
+
if (combinator === null) {
|
|
303
|
+
drop(builder, 'unsupported', component.value);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
setCombinator(builder, combinator);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
case 'id':
|
|
310
|
+
builder.specificity[0]++;
|
|
311
|
+
drop(builder, 'id', `#${component.name}`);
|
|
312
|
+
return;
|
|
313
|
+
case 'type':
|
|
314
|
+
builder.specificity[2]++;
|
|
315
|
+
drop(builder, 'element', component.name);
|
|
316
|
+
return;
|
|
317
|
+
case 'universal':
|
|
318
|
+
drop(builder, 'universal', '*');
|
|
319
|
+
return;
|
|
320
|
+
case 'attribute':
|
|
321
|
+
builder.specificity[1]++;
|
|
322
|
+
drop(builder, 'attribute', `[${component.name}]`);
|
|
323
|
+
return;
|
|
324
|
+
case 'pseudo-class':
|
|
325
|
+
// Neither wrapper contributes specificity of its own — the payload's classes carry it.
|
|
326
|
+
// The parsed `:global(X)` of cssModules mode. Its payload is an ordinary component array, so
|
|
327
|
+
// the SAME walk reads it — which is also why a non-class payload drops with the payload's
|
|
328
|
+
// own reason here for free, identically to the token-stream branch below.
|
|
329
|
+
if (component.kind === 'global') {
|
|
330
|
+
consumeSelector(builder, component.selector);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// `:root` is not state — it is where an author is SUPPOSED to declare custom properties, and
|
|
334
|
+
// `collectCustomProperties` has already read them by the time this runs. Reported with its
|
|
335
|
+
// own reason so the caller can stay silent about the ordinary token sheet and speak up only
|
|
336
|
+
// when the rule also carries a declaration that would have painted. Warning unconditionally
|
|
337
|
+
// meant every stylesheet with a `:root { --token: … }` block printed "it can never match",
|
|
338
|
+
// about the one construct the docs tell people to write.
|
|
339
|
+
if (component.kind === 'root') {
|
|
340
|
+
drop(builder, 'root', 'root');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (component.kind === 'custom-function') {
|
|
344
|
+
if (component.name === 'global') {
|
|
345
|
+
consumePayload(builder, component.arguments, ':global()');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (component.name === 'deep') {
|
|
349
|
+
// `:deep(X)` reaches THROUGH a scope boundary into X — the same relation `>>>` and
|
|
350
|
+
// `::v-deep` express, so the gap into X's own tokens is `deep`, overriding the
|
|
351
|
+
// descendant lightningcss reports just before it.
|
|
352
|
+
setCombinator(builder, 'deep');
|
|
353
|
+
consumePayload(builder, component.arguments, ':deep()');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// `:active` is the ONE state pseudo-class this module keeps, and it is not an exception to
|
|
358
|
+
// the "a selector RN can never match" principle above — it is the one state the ENGINE
|
|
359
|
+
// actually knows, because the press machine owns it. It is kept as an ordinary compound
|
|
360
|
+
// TOKEN (`.btn:active` -> tokens ['btn', ':active'], combinator 'none'), so specificity,
|
|
361
|
+
// source order, scoping and the resolve cache all keep working with no new concept in the
|
|
362
|
+
// registry: the engine adds the token to a pressed node's class list and the existing
|
|
363
|
+
// matcher does the rest. A CSS identifier cannot carry an unescaped `:`, so the token can
|
|
364
|
+
// never collide with a real class name.
|
|
365
|
+
//
|
|
366
|
+
// `:hover` / `:focus` stay dropped deliberately — RN has no hover or focus state the engine
|
|
367
|
+
// owns — as do `:nth-child` and the rest. Widening past `:active` is its own decision, and
|
|
368
|
+
// the limit it will hit is the registry's resolve cache: it is capped at 512 entries and
|
|
369
|
+
// CLEARS WHOLE rather than evicting, so states that COMBINE multiply the distinct class
|
|
370
|
+
// strings on a screen and can drop the cache, which breaks the identity `isAlreadyPublished`
|
|
371
|
+
// depends on for every node at once.
|
|
372
|
+
if (component.kind === STATE_PSEUDO_CLASS) {
|
|
373
|
+
builder.specificity[1]++;
|
|
374
|
+
// ...but NOT through a scope boundary. `:deep(.b:active)` already dropped, because a
|
|
375
|
+
// custom-function payload is a raw token stream this walk re-parses; `.a >>> .b:active`
|
|
376
|
+
// did NOT, because `>>>` is a real combinator and the walk reaches the pseudo-class
|
|
377
|
+
// normally. Two spellings of one relation behaving differently is the bug, and the
|
|
378
|
+
// decision (2026-08-23) is to refuse BOTH: a deep selector reaches into another
|
|
379
|
+
// component's internals, and the state token is only meaningful on the node whose press
|
|
380
|
+
// machine owns it — which is exactly the node a deep rule cannot predict.
|
|
381
|
+
if (builder.combinators.includes('deep') ||
|
|
382
|
+
builder.pending === 'deep') {
|
|
383
|
+
drop(builder, 'pseudo-class', 'active through a deep combinator');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (!IS_STATE_TOKEN_ENABLED) {
|
|
387
|
+
drop(builder, 'state-pseudo-class', 'active');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
pushToken(builder, STATE_TOKEN);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// `:not()`/`:is()` take the specificity of their argument rather than a flat 1, but they are
|
|
394
|
+
// dropped here regardless, and only a KEPT selector's specificity is ever read.
|
|
395
|
+
builder.specificity[1]++;
|
|
396
|
+
drop(builder, 'pseudo-class', component.kind === 'custom' || component.kind === 'custom-function'
|
|
397
|
+
? component.name
|
|
398
|
+
: component.kind);
|
|
399
|
+
return;
|
|
400
|
+
case 'pseudo-element':
|
|
401
|
+
if (component.kind === 'custom' &&
|
|
402
|
+
DEEP_PSEUDO_ELEMENTS.has(component.name)) {
|
|
403
|
+
setCombinator(builder, 'deep');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
builder.specificity[2]++;
|
|
407
|
+
drop(builder, 'pseudo-element', component.kind === 'custom' || component.kind === 'custom-function'
|
|
408
|
+
? component.name
|
|
409
|
+
: component.kind);
|
|
410
|
+
return;
|
|
411
|
+
// `namespace` (`ns|div`) and `nesting` (`&`) both need context this compiler does not have.
|
|
412
|
+
default:
|
|
413
|
+
drop(builder, 'unsupported', component.type);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* `selectors` is lightningcss's own `rule.value.selectors` (an array of selector-component
|
|
419
|
+
* arrays). Each entry is one comma-separated selector and is judged on its own: a list where some
|
|
420
|
+
* parts survive and some drop yields both a match and a drop.
|
|
421
|
+
*/
|
|
422
|
+
function consumeSelector(builder, selector) {
|
|
423
|
+
for (const component of selector) {
|
|
424
|
+
if (!isSelectorComponent(component)) {
|
|
425
|
+
drop(builder, 'unsupported', String(component));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
consumeComponent(builder, component);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
export function selectorsToMatches(selectors, filename) {
|
|
432
|
+
if (!Array.isArray(selectors))
|
|
433
|
+
return { matches: [], dropped: [] };
|
|
434
|
+
const matches = [];
|
|
435
|
+
const dropped = [];
|
|
436
|
+
for (const selector of selectors) {
|
|
437
|
+
if (!Array.isArray(selector) || selector.length === 0)
|
|
438
|
+
continue;
|
|
439
|
+
const builder = createBuilder();
|
|
440
|
+
consumeSelector(builder, selector);
|
|
441
|
+
// No class survived and nothing explained why (a lone `::v-deep`, an empty compound) — there
|
|
442
|
+
// is still nothing to register, so say so rather than emitting a match with zero tokens.
|
|
443
|
+
if (builder.drop === null && builder.tokens.length === 0)
|
|
444
|
+
drop(builder, 'unsupported', 'no class selector');
|
|
445
|
+
const problem = builder.drop;
|
|
446
|
+
if (problem !== null) {
|
|
447
|
+
dropped.push(problem);
|
|
448
|
+
// `root` is returned, never announced from here — see DROP_EXPLANATION.root.
|
|
449
|
+
if (problem.reason === 'root')
|
|
450
|
+
continue;
|
|
451
|
+
// Its own sentence, because the shared one below ends in "can never match in React Native"
|
|
452
|
+
// and that is FALSE here — the pressed look is fully supported, by a different route. A
|
|
453
|
+
// warning that misdescribes the cause is worse than none: it sends the reader to the engine.
|
|
454
|
+
if (problem.reason === 'state-pseudo-class') {
|
|
455
|
+
console.warn(`[@symbiote-native/css-parser] ${filename}: dropped \`:${problem.detail}\` — pseudo-class state is currently disabled in this parser. Use a functional style instead: style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}, which every lowering transform compiles to style + activeStyle.`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
console.warn(`[@symbiote-native/css-parser] ${filename}: dropped a rule on \`${problem.detail}\` — ${DROP_EXPLANATION[problem.reason]}, so it can never match in React Native.`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
matches.push({
|
|
462
|
+
tokens: builder.tokens,
|
|
463
|
+
combinators: builder.combinators,
|
|
464
|
+
specificity: builder.specificity,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return { matches, dropped };
|
|
468
|
+
}
|
|
@@ -1,6 +1,34 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type IStyleRule } from '../lightning/rules.ts';
|
|
2
2
|
export interface ICompiledCssFile {
|
|
3
3
|
code: string;
|
|
4
4
|
}
|
|
5
5
|
export declare function isCssModuleFile(filename: string): boolean;
|
|
6
|
-
export
|
|
6
|
+
export type ICompiledCssModule = {
|
|
7
|
+
/** The rules, their tokens already carrying the scope. */
|
|
8
|
+
rules: readonly IStyleRule[];
|
|
9
|
+
/** The default export: authored name -> the token(s) to put in the markup. */
|
|
10
|
+
classMap: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* One CSS source compiled as a CSS MODULE — a standalone `.module.*` file and a Vue
|
|
14
|
+
* `<style module>` block are the same thing and go through this, so neither can register a class
|
|
15
|
+
* under a name the other would not.
|
|
16
|
+
*
|
|
17
|
+
* The scope tail is our own `hashFilePath`, not lightningcss's `[hash]`: the runtime registry
|
|
18
|
+
* parses this tail to factor a scope back out (SCOPE_TAIL_PATTERN in
|
|
19
|
+
* core/engine/src/style-registry) and its alphabet is lowercase base36, which lightningcss's
|
|
20
|
+
* mixed-case hash does not fit — that would silently kill scoped-token base layering. Same hash
|
|
21
|
+
* the `<style scoped>` and Svelte scopers use, so all three scoping shapes stay one algorithm.
|
|
22
|
+
*/
|
|
23
|
+
export declare function compileCssModule(css: string, filename: string): ICompiledCssModule;
|
|
24
|
+
/**
|
|
25
|
+
* The names a `.module.*` file's default export actually carries.
|
|
26
|
+
*
|
|
27
|
+
* The `.d.ts` generator MUST read them from here rather than re-deriving names off the raw
|
|
28
|
+
* source: what a rule MATCHES on and what the module EXPORTS are different sets. A compound
|
|
29
|
+
* rule `.card.big` matches on both its tokens, but only the names the author wrote as classes
|
|
30
|
+
* are exports. Typing off a re-derived set invents members that are `undefined` at runtime and
|
|
31
|
+
* hides valid ones behind a TS2339.
|
|
32
|
+
*/
|
|
33
|
+
export declare function moduleClassNames(source: string, filename: string): Promise<string[]>;
|
|
34
|
+
export declare function compileCssFile(source: string, filename: string): Promise<ICompiledCssFile>;
|