@symbiote-native/css-parser 0.3.0 → 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.
@@ -1,98 +0,0 @@
1
- // What a caller doing its own scope-suffixing (Vue's <style scoped>/<style module>, Svelte's
2
- // <style>, a standalone .module.css file) must leave alone because the author wrote `:global(...)`
3
- // around it. parseCSS's output cannot answer that on its own: extractClassName UNWRAPS `:global()`
4
- // (so `:global(.reset)` parses to the same `reset` key a plain `.reset` would) and its return
5
- // shape is deliberately just `{ className: style }`, with no per-key metadata.
6
- //
7
- // The escape hatch is asked about at TWO levels, and they are genuinely different questions:
8
- //
9
- // :global(.reset) key `reset` — the whole selector is outside the scope
10
- // .card :global(.reset) key `cardReset` — only the `reset` TOKEN is
11
- //
12
- // `globalClassNamesIn` answers the first (which registered KEY stays unsuffixed),
13
- // `globalClassTokensIn` the second (which MARKUP token stays unsuffixed). A partial `:global()`
14
- // needs both to disagree: the rule as a whole is scoped, because `.card` is the file's own, yet
15
- // the `reset` half must reach the unscoped markup it was written for. Suffixing it anyway
16
- // scope-mangles the author's escape hatch into something that matches nothing.
17
- //
18
- // Both walk the selectors through the parser's own tokenizer rather than a private regex — the
19
- // names they hand back are compared against parseCSS's keys and against the markup tokens
20
- // classTokensIn produces, so any independent spelling of "what class does this selector name" is
21
- // one more chance to disagree with the pipeline it feeds.
22
- import postcss from 'postcss';
23
- import { extractClassName, extractClassTokens, globalPayloadsIn, } from "./parser/index.js";
24
- function eachSelector(css, options, visit) {
25
- if (!css || typeof css !== 'string')
26
- return;
27
- const root = postcss.parse(css, { from: options?.filename });
28
- // Dropped ahead of the rule walk for the same reason parseCSS drops them, silently here since
29
- // that pass already warned about each one.
30
- root.walkAtRules(atRule => {
31
- atRule.remove();
32
- });
33
- root.walkRules(rule => {
34
- for (const selector of rule.selector.split(','))
35
- visit(selector.trim());
36
- });
37
- }
38
- // In source order, so a caller can compare this list against the selector's full token list
39
- // position by position.
40
- function globalTokensOf(selector) {
41
- const tokens = [];
42
- for (const payload of globalPayloadsIn(selector)) {
43
- for (const token of extractClassTokens(payload) ?? [])
44
- tokens.push(token);
45
- }
46
- return tokens;
47
- }
48
- /**
49
- * The registered keys whose ENTIRE selector lived outside the file's scope — `:global(.reset)` →
50
- * `reset`, `:global(.btn.primary)` → `btnPrimary`. These register under their plain name and get
51
- * no scope suffix.
52
- *
53
- * A selector with a scoped part (`.card :global(.reset)` → `cardReset`) is deliberately absent:
54
- * the rule still only applies where the file's own `.card` does, so its collapsed key belongs to
55
- * this file. Only its `:global()` half escapes, which is {@link globalClassTokensIn}'s answer.
56
- */
57
- export function globalClassNamesIn(css, options) {
58
- const names = new Set();
59
- eachSelector(css, options, selector => {
60
- const tokens = extractClassTokens(selector);
61
- if (tokens === null)
62
- return;
63
- // Compared position by position rather than as a subset: `.card :global(.card)` repeats a
64
- // token, and "every token appears somewhere in a payload" would read that as fully global.
65
- const globalTokens = globalTokensOf(selector);
66
- if (globalTokens.length !== tokens.length)
67
- return;
68
- if (!tokens.every((token, index) => token === globalTokens[index]))
69
- return;
70
- const name = extractClassName(selector);
71
- if (name !== null)
72
- names.add(name);
73
- });
74
- return names;
75
- }
76
- /**
77
- * Every class token that came out of a `:global(...)` payload, wherever in a selector it sat —
78
- * `.card :global(.legacy-widget) span` → `{ legacyWidget }`.
79
- *
80
- * This is the set a scope-suffixing caller subtracts from the tokens it owns. A token is in it
81
- * whether or not the selector around it was scoped, which is the whole point: the author reached
82
- * for `:global()` precisely because that name is spelled the same way in markup this file does
83
- * not own, and a suffix would break the match it was reaching for.
84
- *
85
- * A selector the parser rejects contributes nothing — it registers no key, so it names nothing to
86
- * exempt, and letting its payload leak in here would unscope a token some other rule legitimately
87
- * owns.
88
- */
89
- export function globalClassTokensIn(css, options) {
90
- const tokens = new Set();
91
- eachSelector(css, options, selector => {
92
- if (extractClassTokens(selector) === null)
93
- return;
94
- for (const token of globalTokensOf(selector))
95
- tokens.add(token);
96
- });
97
- return tokens;
98
- }
@@ -1,59 +0,0 @@
1
- export type ICssParserOptions = {
2
- filename?: string;
3
- };
4
- export declare function kebabToCamel(value: string): string;
5
- /**
6
- * The payload of every `:global(...)` in a selector, wrapper removed and in source order:
7
- * `.card :global(.legacy) span` → `['.legacy']`, `:global(.a):global(.b)` → `['.a', '.b']`.
8
- *
9
- * The inverse view of {@link stripGlobalWrappers}: that one keeps everything BUT the wrappers,
10
- * this one keeps only what they held. Both share {@link closingParenIndex}, so "where does this
11
- * `:global(` end" has a single answer — the caller-side scope-suffix question needs to know which
12
- * tokens came out of a payload, and re-finding them with a second regex is how the two would
13
- * drift apart on `:global(.a:not(.b))`.
14
- *
15
- * A nested wrapper is left inside the payload it sits in; tokenizing the payload erases it.
16
- */
17
- export declare function globalPayloadsIn(selector: string): string[];
18
- /**
19
- * Extract a camelCase class name from a CSS selector, or `null` if the selector has no RN
20
- * equivalent (pseudo-classes/-elements, bare element selectors, the universal selector — RN has
21
- * no element-selector concept, so those would just pollute the output).
22
- *
23
- * - `.card` → `'card'`
24
- * - `#header` → `'header'`
25
- * - `.btn.primary` → `'btnPrimary'` (compound)
26
- * - `.card .title` / `.card > .title` → `'cardTitle'` (descendant/child, flattened)
27
- * - `[data-theme]` → `'dataTheme'` (attribute)
28
- * - `.my-class-name` → `'myClassName'` (kebab → camel)
29
- * - `.card :global(.reset)` → `'cardReset'` (the `:global()` wrapper is erased, its payload kept)
30
- */
31
- export declare function extractClassName(selector: string): string | null;
32
- /**
33
- * The individual camelCase class tokens a selector is built from — the un-collapsed form of
34
- * {@link extractClassName}. `.btn.primary` → `['btn', 'primary']`, `.card .title` →
35
- * `['card', 'title']`, `.card` → `['card']`; `null` on the same selectors extractClassName
36
- * rejects.
37
- *
38
- * Needed by every caller that scope-suffixes class names (Vue `<style scoped>`, a Svelte
39
- * `<style>` block): the markup those callers rewrite says `class="btn primary"`, so `btn` and
40
- * `primary` are the names they must recognize as locally defined — the collapsed `btnPrimary`
41
- * key appears nowhere in the markup and would leave both tokens unscoped.
42
- *
43
- * Tokens from inside a `:global(...)` are included here too, since the rule still only matches an
44
- * element carrying them. They are the ones a caller must NOT suffix, which is a distinction this
45
- * list does not carry — `globalClassTokensIn` (../global-selectors.ts) is where it lives.
46
- */
47
- export declare function extractClassTokens(selector: string): string[] | null;
48
- /**
49
- * Every registered class key in a stylesheet, mapped back to the class tokens it was built from
50
- * (`.card.big { }` → `cardBig` → `['card', 'big']`). Build-time only, same as {@link parseCSS},
51
- * whose rule walk this mirrors — at-rules are dropped first for the same reason, silently here
52
- * since parseCSS already warns about them on its own pass.
53
- */
54
- export declare function classTokensIn(css: string, options?: ICssParserOptions): Map<string, string[]>;
55
- /**
56
- * Parse a plain CSS string into a `{ className: RNStyleObject }` map. Build-time only — never
57
- * ship this in the app's native JS bundle; it is meant to run inside a Metro transformer.
58
- */
59
- export declare function parseCSS(css: string, options?: ICssParserOptions): Record<string, Record<string, unknown>>;
@@ -1,338 +0,0 @@
1
- // CSS → React Native style-object compiler. `extractClassName` and the CSS-custom-property/
2
- // `var()` resolution machinery are framework/target-agnostic. `evaluateCalc` treats `px` as
3
- // identity (RN has no cell grid to scale against) and `rem`/`em` as scaled by the same
4
- // {@link REM_TO_PX} constant as a bare value (see values.ts). `mapCSSProperty` (properties.ts)
5
- // targets RN's `ViewStyle`/`TextStyle`.
6
- import postcss from 'postcss';
7
- import valueParser from 'postcss-value-parser';
8
- import { mapCSSProperty } from "../properties.js";
9
- import { REM_TO_PX } from "../values.js";
10
- //#region Selector utilities
11
- // Exported: the SFC style compiler (metro-vue-transformer.js) reuses this exact conversion to
12
- // normalize a template's kebab-case class="section-label" authoring to the camelCase key this
13
- // module already registers CSS selectors under, so both spellings resolve to the same style.
14
- export function kebabToCamel(value) {
15
- return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
16
- }
17
- function capitalize(value) {
18
- return value.charAt(0).toUpperCase() + value.slice(1);
19
- }
20
- function unescapeIdentifier(value) {
21
- return value.replace(/\\(.)/g, '$1');
22
- }
23
- const GLOBAL_PSEUDO_OPEN = ':global(';
24
- // Index of the `)` closing the `(` at `openIndex`, or -1 if the selector is unbalanced. Counting
25
- // depth rather than reaching for the next `)` is what keeps `:global(.a:not(.b))` in one piece.
26
- function closingParenIndex(value, openIndex) {
27
- let depth = 0;
28
- for (let index = openIndex; index < value.length; index++) {
29
- if (value[index] === '(')
30
- depth++;
31
- else if (value[index] === ')' && --depth === 0)
32
- return index;
33
- }
34
- return -1;
35
- }
36
- /**
37
- * Erase every `:global(...)` wrapper, leaving its payload in place: `.card :global(.legacy) span`
38
- * becomes `.card .legacy span`.
39
- *
40
- * `:global()` says a part of the selector lives outside the file's scope; it never changes which
41
- * classes an element must carry for the rule to match. So the wrapper is gone before any selector
42
- * shape is recognized below, and its payload participates exactly as if it had been written bare.
43
- * Which of the resulting tokens then gets a scope suffix is a separate, caller-side question that
44
- * `globalClassTokensIn` (../global-selectors.ts) answers, off the payloads {@link globalPayloadsIn}
45
- * hands back.
46
- *
47
- * This follows SVELTE, not Vue, and the two genuinely disagree. Svelte erases the wrapper per
48
- * relative selector and keeps the rest of the chain scoped (`.vendors/svelte-5.53.12-src/compiler/
49
- * phases/3-transform/css/index.js`, `ComplexSelector`: a part flagged `is_global` keeps its inner
50
- * selectors and only skips the scope class; `css-prune.js`'s `apply_selector` sets
51
- * `metadata.scoped` on every part that is not an outer `:global`). Vue's `pluginScoped` instead
52
- * does `selector.replaceWith(n.nodes[0])` on `:global` (`.vendors/vue/packages/compiler-sfc/src/
53
- * style/pluginScoped.ts`), which throws the REST of the chain away — `.card :global(.reset)`
54
- * degrades to a stylesheet-wide `.reset`. One registry serves React, Vue, Angular and Svelte
55
- * alike, so the rule that silently widens a rule's reach beyond what the author wrote is the
56
- * wrong one to standardize on; Vue itself steers the reach-into-a-child case to `:deep()`.
57
- */
58
- function stripGlobalWrappers(selector) {
59
- let result = selector;
60
- let start = result.indexOf(GLOBAL_PSEUDO_OPEN);
61
- while (start !== -1) {
62
- const close = closingParenIndex(result, start + GLOBAL_PSEUDO_OPEN.length - 1);
63
- // Unbalanced: leave the text alone and let the pseudo-class guard below drop the whole rule,
64
- // the same answer any other unparseable selector gets.
65
- if (close === -1)
66
- return result;
67
- const payload = result.slice(start + GLOBAL_PSEUDO_OPEN.length, close).trim();
68
- result = result.slice(0, start) + payload + result.slice(close + 1);
69
- // Re-search from the same offset: a payload may itself hold a `:global(...)`, and each pass
70
- // removes one wrapper, so this terminates.
71
- start = result.indexOf(GLOBAL_PSEUDO_OPEN, start);
72
- }
73
- return result;
74
- }
75
- /**
76
- * The payload of every `:global(...)` in a selector, wrapper removed and in source order:
77
- * `.card :global(.legacy) span` → `['.legacy']`, `:global(.a):global(.b)` → `['.a', '.b']`.
78
- *
79
- * The inverse view of {@link stripGlobalWrappers}: that one keeps everything BUT the wrappers,
80
- * this one keeps only what they held. Both share {@link closingParenIndex}, so "where does this
81
- * `:global(` end" has a single answer — the caller-side scope-suffix question needs to know which
82
- * tokens came out of a payload, and re-finding them with a second regex is how the two would
83
- * drift apart on `:global(.a:not(.b))`.
84
- *
85
- * A nested wrapper is left inside the payload it sits in; tokenizing the payload erases it.
86
- */
87
- export function globalPayloadsIn(selector) {
88
- const payloads = [];
89
- let start = selector.indexOf(GLOBAL_PSEUDO_OPEN);
90
- while (start !== -1) {
91
- const close = closingParenIndex(selector, start + GLOBAL_PSEUDO_OPEN.length - 1);
92
- // Unbalanced: the same answer stripGlobalWrappers gives — stop, and let the selector reach
93
- // the pseudo-class guard that drops the whole rule.
94
- if (close === -1)
95
- return payloads;
96
- payloads.push(selector.slice(start + GLOBAL_PSEUDO_OPEN.length, close).trim());
97
- start = selector.indexOf(GLOBAL_PSEUDO_OPEN, close + 1);
98
- }
99
- return payloads;
100
- }
101
- /**
102
- * Extract a camelCase class name from a CSS selector, or `null` if the selector has no RN
103
- * equivalent (pseudo-classes/-elements, bare element selectors, the universal selector — RN has
104
- * no element-selector concept, so those would just pollute the output).
105
- *
106
- * - `.card` → `'card'`
107
- * - `#header` → `'header'`
108
- * - `.btn.primary` → `'btnPrimary'` (compound)
109
- * - `.card .title` / `.card > .title` → `'cardTitle'` (descendant/child, flattened)
110
- * - `[data-theme]` → `'dataTheme'` (attribute)
111
- * - `.my-class-name` → `'myClassName'` (kebab → camel)
112
- * - `.card :global(.reset)` → `'cardReset'` (the `:global()` wrapper is erased, its payload kept)
113
- */
114
- export function extractClassName(selector) {
115
- const tokens = extractClassTokens(selector);
116
- return tokens === null ? null : joinClassTokens(tokens);
117
- }
118
- // The collapsed key is nothing but its tokens concatenated, so both forms come from ONE walk of
119
- // the selector — a caller that scope-suffixes names needs the tokens, everything else needs the
120
- // key, and two separate parsers would be two chances to disagree about what `.a\.b.c` means.
121
- function joinClassTokens(tokens) {
122
- return tokens.map((token, index) => (index === 0 ? token : capitalize(token))).join('');
123
- }
124
- /**
125
- * The individual camelCase class tokens a selector is built from — the un-collapsed form of
126
- * {@link extractClassName}. `.btn.primary` → `['btn', 'primary']`, `.card .title` →
127
- * `['card', 'title']`, `.card` → `['card']`; `null` on the same selectors extractClassName
128
- * rejects.
129
- *
130
- * Needed by every caller that scope-suffixes class names (Vue `<style scoped>`, a Svelte
131
- * `<style>` block): the markup those callers rewrite says `class="btn primary"`, so `btn` and
132
- * `primary` are the names they must recognize as locally defined — the collapsed `btnPrimary`
133
- * key appears nowhere in the markup and would leave both tokens unscoped.
134
- *
135
- * Tokens from inside a `:global(...)` are included here too, since the rule still only matches an
136
- * element carrying them. They are the ones a caller must NOT suffix, which is a distinction this
137
- * list does not carry — `globalClassTokensIn` (../global-selectors.ts) is where it lives.
138
- */
139
- export function extractClassTokens(selector) {
140
- // Erased first, ahead of every guard below: `:global(...)` legitimately carries a colon that
141
- // the pseudo-class guards would otherwise trip over, and its payload has to reach the shape
142
- // checks as ordinary selector text.
143
- const trimmed = stripGlobalWrappers(selector.trim()).trim();
144
- if (/^[a-z]+$/i.test(trimmed))
145
- return null;
146
- if (trimmed === '*')
147
- return null;
148
- if (trimmed.startsWith(':'))
149
- return null;
150
- // A pseudo-class/-element trailing a class/id selector (`.card:hover`, `.card::before`) has
151
- // no RN equivalent — RN has no hover/focus/nth-child style variants — so the WHOLE rule is
152
- // dropped, same as a bare `:hover`. Stripping just the pseudo suffix and keeping `.card`'s
153
- // other declarations would be wrong: it'd silently merge hover-only styles into the
154
- // always-applied base style (a real gap an earlier version of this fix had — found by
155
- // manually running the parser on `.card:hover { opacity: 0.5 }` and seeing `opacity` leak
156
- // into `card`'s permanent style). `[...]` is excluded first since an attribute selector's
157
- // value may legitimately contain a colon (`[data-x="a:b"]`).
158
- if (trimmed.replace(/\[[^\]]*\]/g, '').includes(':'))
159
- return null;
160
- // Compound selector (`.btn.primary`, `div.card`) — split on unescaped dots.
161
- if (trimmed.includes('.') && !trimmed.includes(' ') && !trimmed.includes('>')) {
162
- const parts = trimmed.split(/(?<!\\)\./).filter(Boolean);
163
- if (parts.length > 0) {
164
- const startsWithElement = !trimmed.startsWith('.');
165
- const startIndex = startsWithElement ? 1 : 0;
166
- if (startIndex >= parts.length)
167
- return null;
168
- return parts.slice(startIndex).map(part => kebabToCamel(unescapeIdentifier(part)));
169
- }
170
- }
171
- // Descendant/child selector (`.card .title`, `.card > .title`) — flattened into one name.
172
- if (trimmed.includes(' ')) {
173
- const parts = trimmed.split(/\s+(?:>\s*)?/).filter(Boolean);
174
- const classNames = [];
175
- for (const part of parts) {
176
- // Every class of the part, not just the first: a chain link may itself be compound
177
- // (`.card .btn.primary`, and now `.card :global(.btn.primary)` after the erase above), and
178
- // an element has to carry BOTH names for the rule to apply. Taking only `.btn` would
179
- // register the rule under a key that a `class="btn primary"` element never resolves to.
180
- const classMatches = [...part.matchAll(/\.((?:[a-zA-Z0-9_-]|\\.)+)/g)];
181
- if (classMatches.length > 0) {
182
- for (const match of classMatches) {
183
- if (match[1])
184
- classNames.push(unescapeIdentifier(match[1]));
185
- }
186
- continue;
187
- }
188
- const idMatch = part.match(/#((?:[a-zA-Z0-9_-]|\\.)+)/);
189
- if (idMatch?.[1])
190
- classNames.push(unescapeIdentifier(idMatch[1]));
191
- }
192
- if (classNames.length === 0)
193
- return null;
194
- return classNames.map(name => kebabToCamel(name));
195
- }
196
- // Single class selector (`.card`).
197
- const classMatch = trimmed.match(/^\.((?:[a-zA-Z0-9_-]|\\.)+)/);
198
- if (classMatch?.[1])
199
- return [kebabToCamel(unescapeIdentifier(classMatch[1]))];
200
- // ID selector (`#header`).
201
- const idMatch = trimmed.match(/^#((?:[a-zA-Z0-9_-]|\\.)+)/);
202
- if (idMatch?.[1])
203
- return [kebabToCamel(unescapeIdentifier(idMatch[1]))];
204
- // Attribute selector (`[data-theme]`).
205
- const attrMatch = trimmed.match(/^\[([a-zA-Z0-9_-]+)(?:=[^\]]+)?\]/);
206
- if (attrMatch?.[1])
207
- return [kebabToCamel(attrMatch[1])];
208
- return null;
209
- }
210
- /**
211
- * Every registered class key in a stylesheet, mapped back to the class tokens it was built from
212
- * (`.card.big { }` → `cardBig` → `['card', 'big']`). Build-time only, same as {@link parseCSS},
213
- * whose rule walk this mirrors — at-rules are dropped first for the same reason, silently here
214
- * since parseCSS already warns about them on its own pass.
215
- */
216
- export function classTokensIn(css, options) {
217
- const tokensByName = new Map();
218
- if (!css || typeof css !== 'string')
219
- return tokensByName;
220
- const root = postcss.parse(css, { from: options?.filename });
221
- root.walkAtRules(atRule => {
222
- atRule.remove();
223
- });
224
- root.walkRules(rule => {
225
- for (const selector of rule.selector.split(',')) {
226
- const tokens = extractClassTokens(selector.trim());
227
- if (tokens === null || tokens.length === 0)
228
- continue;
229
- tokensByName.set(joinClassTokens(tokens), tokens);
230
- }
231
- });
232
- return tokensByName;
233
- }
234
- //#endregion Selector utilities
235
- //#region var() resolution
236
- function resolveVariables(value, variables) {
237
- if (!value.includes('var('))
238
- return value;
239
- const parsed = valueParser(value);
240
- parsed.walk((node, index, nodes) => {
241
- if (node.type !== 'function' || node.value !== 'var' || node.nodes.length === 0)
242
- return;
243
- const varName = node.nodes[0]?.value;
244
- if (!varName)
245
- return;
246
- const fallbackNode = node.nodes.length > 2 ? node.nodes[2] : undefined;
247
- const resolved = variables.get(varName) ?? fallbackNode?.value ?? '';
248
- if (!resolved)
249
- return;
250
- // Replace the `var(...)` function node in its containing array with a plain word node
251
- // holding the resolved text, instead of mutating `node`'s discriminated `type` in place.
252
- nodes[index] = {
253
- type: 'word',
254
- value: resolveVariables(resolved, variables),
255
- sourceIndex: node.sourceIndex,
256
- sourceEndIndex: node.sourceEndIndex,
257
- };
258
- });
259
- return parsed.toString();
260
- }
261
- //#endregion var() resolution
262
- //#region calc() evaluation
263
- const CALC_TERM_PATTERN = /calc\(([^)]+)\)/g;
264
- const NUMBER_WITH_UNIT_PATTERN = /(-?\d+(?:\.\d+)?)(rem|em|px)?/g;
265
- /**
266
- * Evaluates a narrow shape of `calc()`: a single multiplication, or the first numeric term
267
- * as a fallback. `px` is identity; `rem`/`em` scale by {@link REM_TO_PX}, matching a bare
268
- * dimension value.
269
- */
270
- function evaluateCalc(value) {
271
- if (!value.includes('calc('))
272
- return value;
273
- return value.replace(CALC_TERM_PATTERN, (_, expr) => {
274
- const matches = expr.match(NUMBER_WITH_UNIT_PATTERN) ?? [];
275
- const values = [];
276
- for (const term of matches) {
277
- const numMatch = term.match(/(-?\d+(?:\.\d+)?)(rem|em|px)?/);
278
- if (!numMatch)
279
- continue;
280
- const amount = parseFloat(numMatch[1]);
281
- const unit = numMatch[2];
282
- values.push(unit === 'rem' || unit === 'em' ? amount * REM_TO_PX : amount);
283
- }
284
- if (expr.includes('*')) {
285
- const parts = expr.split('*').map(part => part.trim());
286
- if (parts.length === 2) {
287
- const a = values[0] ?? parseFloat(parts[0]) ?? 0;
288
- const b = parseFloat(parts[1]) || 1;
289
- return String(Math.round(a * b));
290
- }
291
- }
292
- return String(Math.round(values[0] ?? 0));
293
- });
294
- }
295
- //#endregion calc() evaluation
296
- /**
297
- * Parse a plain CSS string into a `{ className: RNStyleObject }` map. Build-time only — never
298
- * ship this in the app's native JS bundle; it is meant to run inside a Metro transformer.
299
- */
300
- export function parseCSS(css, options) {
301
- if (!css || typeof css !== 'string')
302
- return {};
303
- const root = postcss.parse(css, { from: options?.filename });
304
- const styles = {};
305
- const warnedProperties = new Set();
306
- // `@media` (and any other at-rule) is unsupported; drop it before the rule walk below so its
307
- // nested rules never leak into the output.
308
- root.walkAtRules(atRule => {
309
- console.warn(`[@symbiote-native/css-parser] "@${atRule.name}" at-rules are not supported, "@${atRule.name} ${atRule.params}" skipped`);
310
- atRule.remove();
311
- });
312
- const variables = new Map();
313
- root.walkDecls(decl => {
314
- if (decl.prop.startsWith('--'))
315
- variables.set(decl.prop, decl.value);
316
- });
317
- root.walkRules(rule => {
318
- const selectors = rule.selector.split(',').map(selector => selector.trim());
319
- for (const selector of selectors) {
320
- const className = extractClassName(selector);
321
- if (!className)
322
- continue;
323
- const style = {};
324
- rule.walkDecls(decl => {
325
- if (decl.prop.startsWith('--'))
326
- return;
327
- const resolvedValue = evaluateCalc(resolveVariables(decl.value, variables));
328
- const mapped = mapCSSProperty(decl.prop.toLowerCase(), resolvedValue, warnedProperties);
329
- if (mapped)
330
- Object.assign(style, mapped);
331
- });
332
- if (Object.keys(style).length === 0)
333
- continue;
334
- styles[className] = { ...styles[className], ...style };
335
- }
336
- });
337
- return styles;
338
- }