@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.
@@ -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,23 @@
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' | 'state-pseudo-class';
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 const IS_STATE_TOKEN_ENABLED: boolean;
22
+ export declare const STATE_TOKEN = ":active";
23
+ export declare function selectorsToMatches(selectors: unknown, filename: string): ISelectorResult;