@symbiote-native/css-parser 0.4.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 CHANGED
@@ -16,9 +16,10 @@ CSS Modules all work identically regardless of source language.
16
16
 
17
17
  ## Install
18
18
 
19
- Not installed directly by an app — each adapter package (`@symbiote-native/react`,
20
- `@symbiote-native/vue`, `@symbiote-native/angular`) already depends on it and re-exports it via its
21
- own `./metro-css-parser` subpath. Writing a Metro transformer for a new adapter yourself:
19
+ Not installed directly by an app — every adapter package (`@symbiote-native/react`,
20
+ `@symbiote-native/vue`, `@symbiote-native/svelte`, `@symbiote-native/solid`,
21
+ `@symbiote-native/angular`) already depends on it and re-exports it via its own
22
+ `./metro-css-parser` subpath. Writing a Metro transformer for a new adapter yourself:
22
23
 
23
24
  ```bash
24
25
  npm install @symbiote-native/css-parser
@@ -27,10 +28,11 @@ npm install @symbiote-native/css-parser
27
28
  ## Who calls this, and how
28
29
 
29
30
  **An app never imports this package directly.** It runs only inside a Metro transformer, on the
30
- Node build machine — never shipped in the app's native JS bundle. Each adapter package
31
- (`@symbiote-native/react`, `@symbiote-native/vue`, `@symbiote-native/angular`) depends on `@symbiote-native/css-parser`
32
- as a regular dependency and re-exports it via its own `./metro-css-parser` subpath, so a consuming
33
- app's `metro.config.js` wires:
31
+ Node build machine — never shipped in the app's native JS bundle. Every adapter package
32
+ (`@symbiote-native/react`, `@symbiote-native/vue`, `@symbiote-native/svelte`,
33
+ `@symbiote-native/solid`, `@symbiote-native/angular`) depends on `@symbiote-native/css-parser` as a
34
+ regular dependency and re-exports it via its own `./metro-css-parser` subpath, so a consuming app's
35
+ `metro.config.js` wires:
34
36
 
35
37
  ```js
36
38
  // metro.config.js
@@ -133,11 +135,12 @@ import {
133
135
  - It does not implement Tailwind CSS — that needs whole-project class scanning and JIT utility
134
136
  generation, a fundamentally different shape than "one source file reduces to CSS text", and is
135
137
  being designed as a separate, future package.
136
- - It supports `scoped` / `:global()` / CSS Modules and SCSS/Sass/Less/Stylus preprocessing; it does
138
+ - It supports `scoped` / `:global()` / CSS Modules and SCSS/Sass/Less/Stylus preprocessing —
139
+ including Svelte's own `<style>` block (its preprocessor calls this package's
140
+ `compileScopedCss`, the same compile every Vue `<style scoped>` block goes through) — and it does
137
141
  not yet generate a typed `.d.ts` for an **inline** Vue `<style module>` block (only standalone
138
142
  `.module.css` files get the strict, no-index-signature type — Vue's own Volar plugin gives inline
139
- blocks a looser, typo-tolerant type for free) and has no Svelte support yet (no Svelte adapter
140
- exists in SymbioteNative today).
143
+ blocks a looser, typo-tolerant type for free).
141
144
 
142
145
  ## Related packages
143
146
 
@@ -145,6 +148,7 @@ import {
145
148
  `resolveClassName`) this package's compiled output resolves against, and the class+style merge
146
149
  used by every adapter.
147
150
  - [`@symbiote-native/react`](../../adapters/react) / [`@symbiote-native/vue`](../../adapters/vue) /
151
+ [`@symbiote-native/svelte`](../../adapters/svelte) / [`@symbiote-native/solid`](../../adapters/solid) /
148
152
  [`@symbiote-native/angular`](../../adapters/angular) — each depends on this package directly and
149
153
  re-exports it via its own `./metro-css-parser` subpath, so a consuming app needs no extra install
150
154
  step.
@@ -8,7 +8,7 @@ export interface ISelectorMatch {
8
8
  readonly combinators: readonly ISelectorCombinator[];
9
9
  }
10
10
  export interface IDroppedSelector {
11
- readonly reason: 'root' | 'pseudo-class' | 'pseudo-element' | 'attribute' | 'element' | 'id' | 'universal' | 'unsupported';
11
+ readonly reason: 'root' | 'pseudo-class' | 'pseudo-element' | 'attribute' | 'element' | 'id' | 'universal' | 'unsupported' | 'state-pseudo-class';
12
12
  /** e.g. 'hover', '[data-x]', 'div'. */
13
13
  readonly detail: string;
14
14
  }
@@ -18,4 +18,6 @@ export interface ISelectorResult {
18
18
  /** The rest, with a reason. */
19
19
  readonly dropped: readonly IDroppedSelector[];
20
20
  }
21
+ export declare const IS_STATE_TOKEN_ENABLED: boolean;
22
+ export declare const STATE_TOKEN = ":active";
21
23
  export declare function selectorsToMatches(selectors: unknown, filename: string): ISelectorResult;
@@ -54,7 +54,31 @@ const DROP_EXPLANATION = {
54
54
  id: 'React Native has no id selectors',
55
55
  universal: 'React Native has no universal selector',
56
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': '',
57
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;
58
82
  const DEEP_PSEUDO_ELEMENTS = new Set(['v-deep', 'ng-deep']);
59
83
  // Every `SelectorComponent['type']`, so the guard below rejects a shape lightningcss does not
60
84
  // produce instead of trusting any object that happens to carry a `type` string.
@@ -98,6 +122,10 @@ function combinatorFor(value) {
98
122
  return null;
99
123
  }
100
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';
101
129
  function createBuilder() {
102
130
  return {
103
131
  tokens: [],
@@ -197,6 +225,31 @@ function consumePayload(builder, args, wrapper) {
197
225
  const nameIndex = index + (isPseudoElement ? 2 : 1);
198
226
  const name = identAt(args, nameIndex) ?? wrapper;
199
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
+ }
200
253
  drop(builder, isPseudoElement ? 'pseudo-element' : 'pseudo-class', name);
201
254
  index = nameIndex;
202
255
  break;
@@ -301,6 +354,42 @@ function consumeComponent(builder, component) {
301
354
  return;
302
355
  }
303
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
+ }
304
393
  // `:not()`/`:is()` take the specificity of their argument rather than a flat 1, but they are
305
394
  // dropped here regardless, and only a KEPT selector's specificity is ever read.
306
395
  builder.specificity[1]++;
@@ -359,6 +448,13 @@ export function selectorsToMatches(selectors, filename) {
359
448
  // `root` is returned, never announced from here — see DROP_EXPLANATION.root.
360
449
  if (problem.reason === 'root')
361
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
+ }
362
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.`);
363
459
  continue;
364
460
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/css-parser",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Build-time CSS/SCSS/Less/Stylus compiler for SymbioteNative — compiles stylesheets to React Native style objects, resolved at runtime via a cross-adapter class-name registry.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,17 @@
17
17
  "main": "./build/index.js",
18
18
  "module": "./build/index.js",
19
19
  "types": "./build/index.d.ts",
20
+ "keywords": [
21
+ "react-native",
22
+ "symbiote-native",
23
+ "css",
24
+ "scss",
25
+ "css-modules",
26
+ "postcss",
27
+ "lightningcss",
28
+ "metro",
29
+ "build-tool"
30
+ ],
20
31
  "exports": {
21
32
  ".": {
22
33
  "types": "./build/index.d.ts",