@symbiote-native/css-parser 0.2.3 → 0.4.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.
@@ -0,0 +1,52 @@
1
+ import type { CSSModuleExports } from 'lightningcss';
2
+ import { type IStyleObject } from './declarations.ts';
3
+ import { type ISelectorCombinator } from './selectors.ts';
4
+ export interface IStyleRule {
5
+ /** Class names as authored, then through {@link ICompiledCss.exports} when a pattern renamed them. */
6
+ readonly tokens: readonly string[];
7
+ readonly specificity: readonly [number, number, number];
8
+ /** Source order across the whole file, 0-based. Breaks a specificity tie, as the cascade does. */
9
+ readonly order: number;
10
+ readonly style: IStyleObject;
11
+ /**
12
+ * One per gap between tokens. Carried but NOT yet consumed: the registry matches a rule by token
13
+ * SUBSET, so `.a .b` still fires like `.a.b` today (trap 6). Stage 4 is where this starts to
14
+ * mean something — it needs parent pointers, which only exist after the engine's commit walk.
15
+ */
16
+ readonly combinators: readonly ISelectorCombinator[];
17
+ }
18
+ export interface ICompileRulesOptions {
19
+ readonly filename: string;
20
+ /** Root font size for `rem`. */
21
+ readonly remToPx?: number;
22
+ /**
23
+ * A lightningcss CSS-Modules pattern, `[local]` first — `[local]__module__<hash>` for a
24
+ * `.module.*` file or a Vue `<style module>`, `[local]__data-v-<hash>` for a Vue `<style
25
+ * scoped>`, `[local]__svelte-<hash>` for a Svelte `<style>`. Omitted for a plain global `.css`.
26
+ */
27
+ readonly pattern?: string;
28
+ }
29
+ export interface ICompiledCss {
30
+ readonly rules: readonly IStyleRule[];
31
+ /**
32
+ * Authored class name -> the name it registers under, straight out of lightningcss. Empty
33
+ * without a `pattern`. The markup rewriter reads THIS — never its own recomputation of the
34
+ * name, which is the bug class the single-renamer design exists to close.
35
+ */
36
+ readonly exports: Readonly<Record<string, string>>;
37
+ /**
38
+ * The same map unflattened, kept because CSS Modules needs more than the new name: `composes`
39
+ * lives on the entry, and resolving a chain (`.a composes .b composes .c`) walks it. Empty
40
+ * without a `pattern`.
41
+ */
42
+ readonly moduleExports: CSSModuleExports;
43
+ /**
44
+ * Class tokens the rules use that lightningcss did NOT rename. Under a `pattern` that means
45
+ * exactly one thing — the name came out of a `:global()` — so the escape hatch is DERIVED here
46
+ * rather than re-detected by walking selectors a second time (which is all
47
+ * a retired second selector walk ever was). Without a pattern nothing is renamed, so this is
48
+ * simply every token in the file, and no caller has a use for it.
49
+ */
50
+ readonly globals: ReadonlySet<string>;
51
+ }
52
+ export declare function compileCssToRules(css: string, options: ICompileRulesOptions): ICompiledCss;
@@ -0,0 +1,157 @@
1
+ // The glue of the lightningcss-typed pipeline: CSS text in, engine style RULES out. One
2
+ // `transform()` call feeds both halves — `./selectors.ts` for the selector, `./declarations.ts`
3
+ // for the values — and nothing in between re-derives a name from text.
4
+ //
5
+ // WHY a rule instead of a key. The retired text pipeline COLLAPSED a selector into one string
6
+ // key (`.a.b` -> `aB`, camelCased) and the runtime registry reversed that guess by
7
+ // permuting the element's class tokens. Every lossy step in the collapse mapped two different
8
+ // selectors onto one key and the later rule silently overwrote the earlier — the seventh trap in
9
+ // `.claude/rules/style-registry-collisions.md`. A rule carries the TOKEN SET the selector was
10
+ // written with, so the registry matches by subset and there is nothing to guess or reverse.
11
+ //
12
+ // Class names are emitted AS AUTHORED (`card-title` stays `card-title`, plus whatever the scoping
13
+ // rename appended). No camelCase anywhere in this file: normalization is what the trap was made of.
14
+ //
15
+ // Measured 2026-08-20 and the reason this is ONE pass, not two: with `cssModules` on, the visitor
16
+ // still sees the ORIGINAL class names — lightningcss renames AFTER the visitor walk. So the AST
17
+ // gives authored tokens and `exports` gives their renamed spelling, and the renamed CSS text never
18
+ // has to be parsed a second time (which is exactly what `../metro-css-module/index.ts` had to do,
19
+ // and what mangled a scope tail whose base36 hash began with a letter).
20
+ //
21
+ // `:global()` is the one thing the mode DOES change, and it changes shape rather than presence:
22
+ // with `cssModules` off it is a `custom-function` pseudo-class holding a raw token stream, with it
23
+ // on a `kind:'global'` pseudo-class holding a real parsed selector. `./selectors.ts` handles both;
24
+ // either way the tokens come out and `exports` not naming them is what marks them global.
25
+ import { transform } from 'lightningcss';
26
+ import { declarationToStyle, variablesIn, } from "./declarations.js";
27
+ import { selectorsToMatches } from "./selectors.js";
28
+ import { warnOnce } from "../values.js";
29
+ // A conditional at-rule is DROPPED WHOLE, its nested rules with it. There is no media-query engine
30
+ // in React Native, so applying `.responsive` from `@media (min-width: 900px)` would paint it on
31
+ // every phone — worse than not supporting the rule, because it looks supported. The retired text
32
+ // pass dropped these too, but silently: it simply never walked into an at-rule. Measured
33
+ // 2026-08-20: returning `[]` from the at-rule visitor removes it BEFORE the walk descends, so a
34
+ // nested style rule never reaches the collector; without it lightningcss hoists it out.
35
+ //
36
+ // `@keyframes` and `@font-face` need no entry here — neither emits a style rule to begin with.
37
+ const CONDITIONAL_AT_RULES = ['media', 'supports', 'container'];
38
+ // The wrappers `./selectors.ts` unwraps itself. lightningcss implements neither, so it reports both
39
+ // as an unsupported pseudo-class even though the rule survives.
40
+ const HANDLED_PSEUDO_CLASSES = new Set(['global', 'deep']);
41
+ function isHandledPseudoClass(value) {
42
+ if (typeof value !== 'object' || value === null)
43
+ return false;
44
+ const record = { ...value };
45
+ return (record.type === 'UnsupportedPseudoClass' &&
46
+ typeof record.value === 'string' &&
47
+ HANDLED_PSEUDO_CLASSES.has(record.value));
48
+ }
49
+ export function compileCssToRules(css, options) {
50
+ const { filename, pattern, remToPx } = options;
51
+ const collected = [];
52
+ const exports = {};
53
+ const warned = new Set();
54
+ const dropAtRule = (name) => () => {
55
+ warnOnce(warned, `at-rule:${name}`, `[@symbiote-native/css-parser] ${filename}: \`@${name}\` is not supported — the rules ` +
56
+ 'inside it are dropped. React Native evaluates no CSS condition at all; branch in JS ' +
57
+ '(useWindowDimensions, Platform) instead.');
58
+ return [];
59
+ };
60
+ const result = transform({
61
+ filename,
62
+ code: Buffer.from(css),
63
+ // A rule lightningcss cannot use must not take the whole file down: the retired text pass
64
+ // never threw either, and a stylesheet is authored content, not our code.
65
+ errorRecovery: true,
66
+ // Without this `>>>` / `/deep/` die at parse as a dangling combinator, so the selector half
67
+ // never gets the chance to fold them. Inert until stage 4 consumes a combinator.
68
+ nonStandard: { deepSelectorCombinator: true },
69
+ ...(pattern === undefined ? {} : { cssModules: { pattern } }),
70
+ visitor: {
71
+ Rule: {
72
+ ...Object.fromEntries(CONDITIONAL_AT_RULES.map(name => [name, dropAtRule(name)])),
73
+ style(rule) {
74
+ // Every reason but `root` is announced by `selectorsToMatches` itself, with the
75
+ // explanation spelled out for the author — warning again from this level printed the
76
+ // same drop twice. `root` is the exception it hands back silently, because only here
77
+ // are the declarations in scope, and a `:root` block is worth a word ONLY when it
78
+ // carries something that would have painted. Custom properties are its whole purpose
79
+ // and are already collected by `collectCustomProperties`.
80
+ const { matches, dropped } = selectorsToMatches(rule.value.selectors, filename);
81
+ // `importantDeclarations` last: `!important` wins inside its own rule, and a later
82
+ // entry overwrites an earlier one when both name the same RN property.
83
+ const declarations = [
84
+ ...rule.value.declarations.declarations,
85
+ ...rule.value.declarations.importantDeclarations,
86
+ ];
87
+ if (dropped.some(entry => entry.reason === 'root') &&
88
+ declarations.some(declaration => declaration.property !== 'custom')) {
89
+ warnOnce(warned, `root-paints:${filename}`, `[@symbiote-native/css-parser] ${filename}: a \`:root\` rule declares more than ` +
90
+ 'custom properties — everything else in it is dropped, because `:root` matches no ' +
91
+ 'node in React Native. Move those declarations onto a class.');
92
+ }
93
+ for (const match of matches) {
94
+ collected.push({ ...match, declarations });
95
+ }
96
+ },
97
+ },
98
+ },
99
+ });
100
+ // `errorRecovery` keeps a malformed rule from taking the file down, but silence is how a whole
101
+ // rule disappears unnoticed: `examples/angular/**/ApiPlaygroundScreen.css` had a comment closed
102
+ // early by a `.hero-*/` inside it, which swept the real class after it into a garbage selector.
103
+ // The retired text pass "recovered" by registering the garbage; this one drops it — either way
104
+ // the author has to be told.
105
+ //
106
+ // Two things this loop must NOT do. It must not claim an outcome — a SelectorError drops the
107
+ // rule but a value-level one drops only the declaration, and asserting the wrong one is worse
108
+ // than staying quiet about it. And it must not repeat a complaint we deliberately answer:
109
+ // lightningcss does not implement `:global()` or `:deep()`, so it warns "not recognized as a
110
+ // valid pseudo-class" for both while `./selectors.ts` unwraps them and keeps the rule. Passing
111
+ // that through told the author their working rule was thrown away.
112
+ for (const warning of result.warnings) {
113
+ if (isHandledPseudoClass(warning.value))
114
+ continue;
115
+ const where = `${warning.loc.line}:${warning.loc.column}`;
116
+ warnOnce(warned, `lightningcss:${warning.type}:${warning.message}`, `[@symbiote-native/css-parser] ${filename}:${where}: ${warning.message}`);
117
+ }
118
+ // Sorted: `exports` is a Rust HashMap whose iteration order is randomized PER PROCESS, and this
119
+ // map is emitted into a generated module — an unsorted one churns Metro's content cache.
120
+ for (const [local, entry] of Object.entries(result.exports ?? {}).sort(([left], [right]) => (left < right ? -1 : 1))) {
121
+ exports[local] = entry.name;
122
+ }
123
+ // Custom properties come from their own pass because nothing guarantees `:root` is declared
124
+ // before the rule that reads it — the retired text pass had the same two-phase shape.
125
+ const variables = variablesIn(css, filename);
126
+ const context = {
127
+ filename,
128
+ variables,
129
+ ...(remToPx === undefined ? {} : { remToPx }),
130
+ };
131
+ const globals = new Set();
132
+ const rules = collected.map((rule, order) => ({
133
+ tokens: rule.tokens.map(token => {
134
+ const renamed = exports[token];
135
+ if (renamed === undefined)
136
+ globals.add(token);
137
+ return renamed ?? token;
138
+ }),
139
+ specificity: rule.specificity,
140
+ combinators: rule.combinators,
141
+ order,
142
+ style: rule.declarations.reduce((style, declaration) => ({
143
+ ...style,
144
+ ...declarationToStyle(declaration, context),
145
+ }), {}),
146
+ }));
147
+ // A rule whose every declaration was dropped — all unsupported, or all `var()`s declared in
148
+ // another file — has nothing to contribute, and keeping it only adds a candidate the registry
149
+ // must consider on every lookup of those tokens.
150
+ const painting = rules.filter(rule => Object.keys(rule.style).length > 0);
151
+ return {
152
+ rules: painting,
153
+ exports,
154
+ moduleExports: result.exports ?? {},
155
+ globals,
156
+ };
157
+ }
@@ -0,0 +1,21 @@
1
+ export type ISelectorCombinator = 'none' | 'descendant' | 'child' | 'next-sibling' | 'later-sibling' | 'deep';
2
+ export interface ISelectorMatch {
3
+ /** Class names AS AUTHORED — NO camelCase, NO collapsing, in source order. */
4
+ readonly tokens: readonly string[];
5
+ /** CSS specificity (a,b,c) = (id, class/attr/pseudo-class, element/pseudo-element). */
6
+ readonly specificity: readonly [number, number, number];
7
+ /** The combinators between the tokens, one per gap (length === tokens.length - 1). */
8
+ readonly combinators: readonly ISelectorCombinator[];
9
+ }
10
+ export interface IDroppedSelector {
11
+ readonly reason: 'root' | 'pseudo-class' | 'pseudo-element' | 'attribute' | 'element' | 'id' | 'universal' | 'unsupported';
12
+ /** e.g. 'hover', '[data-x]', 'div'. */
13
+ readonly detail: string;
14
+ }
15
+ export interface ISelectorResult {
16
+ /** One per comma-separated selector that is usable. */
17
+ readonly matches: readonly ISelectorMatch[];
18
+ /** The rest, with a reason. */
19
+ readonly dropped: readonly IDroppedSelector[];
20
+ }
21
+ export declare function selectorsToMatches(selectors: unknown, filename: string): ISelectorResult;
@@ -0,0 +1,372 @@
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
+ };
58
+ const DEEP_PSEUDO_ELEMENTS = new Set(['v-deep', 'ng-deep']);
59
+ // Every `SelectorComponent['type']`, so the guard below rejects a shape lightningcss does not
60
+ // produce instead of trusting any object that happens to carry a `type` string.
61
+ const SELECTOR_COMPONENT_TYPES = new Set([
62
+ 'combinator',
63
+ 'universal',
64
+ 'namespace',
65
+ 'type',
66
+ 'id',
67
+ 'class',
68
+ 'attribute',
69
+ 'pseudo-class',
70
+ 'pseudo-element',
71
+ 'nesting',
72
+ ]);
73
+ function isRecord(value) {
74
+ return typeof value === 'object' && value !== null;
75
+ }
76
+ function isSelectorComponent(value) {
77
+ return (isRecord(value) &&
78
+ typeof value.type === 'string' &&
79
+ SELECTOR_COMPONENT_TYPES.has(value.type));
80
+ }
81
+ function isTokenOrValue(value) {
82
+ return isRecord(value) && typeof value.type === 'string';
83
+ }
84
+ /** The subset of lightningcss combinators this pipeline can act on; the rest drop the rule. */
85
+ function combinatorFor(value) {
86
+ switch (value) {
87
+ case 'descendant':
88
+ case 'child':
89
+ case 'next-sibling':
90
+ case 'later-sibling':
91
+ return value;
92
+ // `>>>` and `/deep/` differ only in spelling; a caller that treats them apart would be
93
+ // encoding CSS trivia, not a matching rule.
94
+ case 'deep-descendant':
95
+ case 'deep':
96
+ return 'deep';
97
+ default:
98
+ return null;
99
+ }
100
+ }
101
+ function createBuilder() {
102
+ return {
103
+ tokens: [],
104
+ combinators: [],
105
+ specificity: [0, 0, 0],
106
+ pending: 'none',
107
+ drop: null,
108
+ };
109
+ }
110
+ function pushToken(builder, name) {
111
+ if (builder.tokens.length > 0)
112
+ builder.combinators.push(builder.pending);
113
+ builder.tokens.push(name);
114
+ builder.pending = 'none';
115
+ }
116
+ function setCombinator(builder, next) {
117
+ // A descendant never downgrades an explicit combinator already pending. lightningcss brackets
118
+ // `::v-deep` with a synthetic descendant on BOTH sides, and inside a `:global()` token stream
119
+ // `>` arrives surrounded by whitespace — in either case the second descendant would otherwise
120
+ // erase the real combinator the author wrote.
121
+ if (next === 'descendant' && builder.pending !== 'none')
122
+ return;
123
+ builder.pending = next;
124
+ }
125
+ function drop(builder, reason, detail) {
126
+ builder.drop ??= { reason, detail };
127
+ }
128
+ //#region custom-function payload token streams
129
+ /**
130
+ * Fold the payload of a custom-function pseudo-class into the builder as if its classes had been
131
+ * written bare. Serves both `:global(X)` and `:deep(X)` — lightningcss implements neither, so both
132
+ * arrive in the identical shape and one unwrapper covers them.
133
+ *
134
+ * This is the RAW-TOKEN half of `:global()`, which is the shape a PLAIN `.css` file produces —
135
+ * it is not run through cssModules, so `:global()` stays a `custom-function` pseudo-class whose
136
+ * arguments are an unparsed token stream (measured, lightningcss 1.32): `:global(.a > .b)` arrives
137
+ * as delim `.` · ident `a` · white-space · delim `>` · white-space · delim `.` · ident `b`.
138
+ * With cssModules ON the same source arrives as `kind:'global'` carrying a real parsed selector,
139
+ * handled where that kind is matched — `:global()` never disappears, it changes shape, and
140
+ * assuming the mode erased it is what silently killed the rule in every `.module.*` file.
141
+ *
142
+ * Neither wrapper changes which classes an element must carry — `:global()` only says a name lives
143
+ * outside the file's scope, `:deep()` only says the match may cross a scope boundary. So the
144
+ * payload participates exactly as if unwrapped, the rule `stripGlobalWrappers` follows in the text
145
+ * parser. `wrapper` is the authored spelling, used only so a drop warning names the right one.
146
+ */
147
+ function consumePayload(builder, args, wrapper) {
148
+ for (let index = 0; index < args.length; index++) {
149
+ const argument = args[index];
150
+ if (!isTokenOrValue(argument) || argument.type !== 'token') {
151
+ drop(builder, 'unsupported', wrapper);
152
+ return;
153
+ }
154
+ const token = argument.value;
155
+ switch (token.type) {
156
+ case 'white-space':
157
+ setCombinator(builder, 'descendant');
158
+ break;
159
+ case 'delim': {
160
+ if (token.value === '>') {
161
+ setCombinator(builder, 'child');
162
+ break;
163
+ }
164
+ if (token.value === '+') {
165
+ setCombinator(builder, 'next-sibling');
166
+ break;
167
+ }
168
+ if (token.value === '~') {
169
+ setCombinator(builder, 'later-sibling');
170
+ break;
171
+ }
172
+ if (token.value === '*') {
173
+ drop(builder, 'universal', '*');
174
+ break;
175
+ }
176
+ if (token.value !== '.') {
177
+ drop(builder, 'unsupported', `${wrapper.slice(0, -1)}${token.value})`);
178
+ break;
179
+ }
180
+ // A class is two tokens — delim `.` then the ident. A `.` with no ident behind it means
181
+ // the payload is malformed, and nothing about it is then trustworthy.
182
+ const name = identAt(args, index + 1);
183
+ if (name === null) {
184
+ drop(builder, 'unsupported', wrapper);
185
+ return;
186
+ }
187
+ builder.specificity[1]++;
188
+ pushToken(builder, name);
189
+ index++;
190
+ break;
191
+ }
192
+ case 'colon': {
193
+ // One colon is a pseudo-class, two a pseudo-element; either way the name is the ident
194
+ // after them, and reporting it is the difference between a warning an author can act on
195
+ // and one that only says ":global()" / ":deep()".
196
+ const isPseudoElement = isColonAt(args, index + 1);
197
+ const nameIndex = index + (isPseudoElement ? 2 : 1);
198
+ const name = identAt(args, nameIndex) ?? wrapper;
199
+ builder.specificity[isPseudoElement ? 2 : 1]++;
200
+ drop(builder, isPseudoElement ? 'pseudo-element' : 'pseudo-class', name);
201
+ index = nameIndex;
202
+ break;
203
+ }
204
+ case 'ident':
205
+ builder.specificity[2]++;
206
+ drop(builder, 'element', token.value);
207
+ break;
208
+ case 'id-hash':
209
+ builder.specificity[0]++;
210
+ drop(builder, 'id', `#${token.value}`);
211
+ break;
212
+ case 'square-bracket-block': {
213
+ // The parsed shape reports `[data-x]` by name, so the token stream reads the name too —
214
+ // otherwise the same CSS would warn differently depending on the cssModules flag. The
215
+ // block's remaining tokens are left to fall through: they can only add a second drop, and
216
+ // the first one already recorded is the one reported.
217
+ const name = identAt(args, index + 1);
218
+ builder.specificity[1]++;
219
+ drop(builder, 'attribute', name === null ? '[…]' : `[${name}]`);
220
+ break;
221
+ }
222
+ default:
223
+ drop(builder, 'unsupported', wrapper);
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ function identAt(args, index) {
229
+ const argument = args[index];
230
+ if (!isTokenOrValue(argument) || argument.type !== 'token')
231
+ return null;
232
+ return argument.value.type === 'ident' ? argument.value.value : null;
233
+ }
234
+ function isColonAt(args, index) {
235
+ const argument = args[index];
236
+ return (isTokenOrValue(argument) &&
237
+ argument.type === 'token' &&
238
+ argument.value.type === 'colon');
239
+ }
240
+ //#endregion custom-function payload token streams
241
+ function consumeComponent(builder, component) {
242
+ switch (component.type) {
243
+ case 'class':
244
+ builder.specificity[1]++;
245
+ pushToken(builder, component.name);
246
+ return;
247
+ case 'combinator': {
248
+ const combinator = combinatorFor(component.value);
249
+ if (combinator === null) {
250
+ drop(builder, 'unsupported', component.value);
251
+ return;
252
+ }
253
+ setCombinator(builder, combinator);
254
+ return;
255
+ }
256
+ case 'id':
257
+ builder.specificity[0]++;
258
+ drop(builder, 'id', `#${component.name}`);
259
+ return;
260
+ case 'type':
261
+ builder.specificity[2]++;
262
+ drop(builder, 'element', component.name);
263
+ return;
264
+ case 'universal':
265
+ drop(builder, 'universal', '*');
266
+ return;
267
+ case 'attribute':
268
+ builder.specificity[1]++;
269
+ drop(builder, 'attribute', `[${component.name}]`);
270
+ return;
271
+ case 'pseudo-class':
272
+ // Neither wrapper contributes specificity of its own — the payload's classes carry it.
273
+ // The parsed `:global(X)` of cssModules mode. Its payload is an ordinary component array, so
274
+ // the SAME walk reads it — which is also why a non-class payload drops with the payload's
275
+ // own reason here for free, identically to the token-stream branch below.
276
+ if (component.kind === 'global') {
277
+ consumeSelector(builder, component.selector);
278
+ return;
279
+ }
280
+ // `:root` is not state — it is where an author is SUPPOSED to declare custom properties, and
281
+ // `collectCustomProperties` has already read them by the time this runs. Reported with its
282
+ // own reason so the caller can stay silent about the ordinary token sheet and speak up only
283
+ // when the rule also carries a declaration that would have painted. Warning unconditionally
284
+ // meant every stylesheet with a `:root { --token: … }` block printed "it can never match",
285
+ // about the one construct the docs tell people to write.
286
+ if (component.kind === 'root') {
287
+ drop(builder, 'root', 'root');
288
+ return;
289
+ }
290
+ if (component.kind === 'custom-function') {
291
+ if (component.name === 'global') {
292
+ consumePayload(builder, component.arguments, ':global()');
293
+ return;
294
+ }
295
+ if (component.name === 'deep') {
296
+ // `:deep(X)` reaches THROUGH a scope boundary into X — the same relation `>>>` and
297
+ // `::v-deep` express, so the gap into X's own tokens is `deep`, overriding the
298
+ // descendant lightningcss reports just before it.
299
+ setCombinator(builder, 'deep');
300
+ consumePayload(builder, component.arguments, ':deep()');
301
+ return;
302
+ }
303
+ }
304
+ // `:not()`/`:is()` take the specificity of their argument rather than a flat 1, but they are
305
+ // dropped here regardless, and only a KEPT selector's specificity is ever read.
306
+ builder.specificity[1]++;
307
+ drop(builder, 'pseudo-class', component.kind === 'custom' || component.kind === 'custom-function'
308
+ ? component.name
309
+ : component.kind);
310
+ return;
311
+ case 'pseudo-element':
312
+ if (component.kind === 'custom' &&
313
+ DEEP_PSEUDO_ELEMENTS.has(component.name)) {
314
+ setCombinator(builder, 'deep');
315
+ return;
316
+ }
317
+ builder.specificity[2]++;
318
+ drop(builder, 'pseudo-element', component.kind === 'custom' || component.kind === 'custom-function'
319
+ ? component.name
320
+ : component.kind);
321
+ return;
322
+ // `namespace` (`ns|div`) and `nesting` (`&`) both need context this compiler does not have.
323
+ default:
324
+ drop(builder, 'unsupported', component.type);
325
+ return;
326
+ }
327
+ }
328
+ /**
329
+ * `selectors` is lightningcss's own `rule.value.selectors` (an array of selector-component
330
+ * arrays). Each entry is one comma-separated selector and is judged on its own: a list where some
331
+ * parts survive and some drop yields both a match and a drop.
332
+ */
333
+ function consumeSelector(builder, selector) {
334
+ for (const component of selector) {
335
+ if (!isSelectorComponent(component)) {
336
+ drop(builder, 'unsupported', String(component));
337
+ continue;
338
+ }
339
+ consumeComponent(builder, component);
340
+ }
341
+ }
342
+ export function selectorsToMatches(selectors, filename) {
343
+ if (!Array.isArray(selectors))
344
+ return { matches: [], dropped: [] };
345
+ const matches = [];
346
+ const dropped = [];
347
+ for (const selector of selectors) {
348
+ if (!Array.isArray(selector) || selector.length === 0)
349
+ continue;
350
+ const builder = createBuilder();
351
+ consumeSelector(builder, selector);
352
+ // No class survived and nothing explained why (a lone `::v-deep`, an empty compound) — there
353
+ // is still nothing to register, so say so rather than emitting a match with zero tokens.
354
+ if (builder.drop === null && builder.tokens.length === 0)
355
+ drop(builder, 'unsupported', 'no class selector');
356
+ const problem = builder.drop;
357
+ if (problem !== null) {
358
+ dropped.push(problem);
359
+ // `root` is returned, never announced from here — see DROP_EXPLANATION.root.
360
+ if (problem.reason === 'root')
361
+ continue;
362
+ 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.`);
363
+ continue;
364
+ }
365
+ matches.push({
366
+ tokens: builder.tokens,
367
+ combinators: builder.combinators,
368
+ specificity: builder.specificity,
369
+ });
370
+ }
371
+ return { matches, dropped };
372
+ }
@@ -1,6 +1,34 @@
1
- import { type ICssParserOptions } from '../parser/index.ts';
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 declare function compileCssFile(source: string, filename: string, options?: ICssParserOptions): Promise<ICompiledCssFile>;
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>;