@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,885 @@
1
+ // Declaration half of the lightningcss-typed CSS pipeline: one lightningcss `Declaration` object
2
+ // (from a visitor, or out of `rule.value.declarations.declarations`) → zero or more React Native
3
+ // style entries.
4
+ //
5
+ // WHY it maps off the typed AST: the retired text mapper re-parsed the declaration TEXT, so it
6
+ // re-derived structure lightningcss already hands over, and it lost what it never looked at.
7
+ // Three shipped bugs came straight from that (traps 4, 5 and the `var()` one in
8
+ // `.claude/rules/style-registry-collisions.md`):
9
+ //
10
+ // padding: 8px 16px text pass → { padding: 8 } the 16 is silently gone
11
+ // width: calc(100% - 24px) text pass → { width: 100 } paints ~100pt wide
12
+ // color: var(--from-other) text pass → { color: 'var(--from-other)' } literal ships to Fabric
13
+ //
14
+ // lightningcss hands all three back typed: a shorthand arrives PRE-EXPANDED as a Rect, a `calc()`
15
+ // arrives as a sum tree with per-operand units, and a `var()` arrives as an `unparsed` declaration
16
+ // carrying real var tokens. So each bug becomes a decision rather than an accident — expand the
17
+ // Rect, evaluate the sum only when every operand reduces to px, and drop an unresolvable `var()`
18
+ // with a warning instead of emitting its text.
19
+ //
20
+ // The property NAME table (`../properties.ts`) outlived the text pass and is still the one place
21
+ // that says what RN calls a property and how the "unsupported property dropped" warning is
22
+ // worded. Only value conversion lives here, against the typed AST.
23
+ //
24
+ // `!important` needs nothing special: lightningcss files those under
25
+ // `declarations.importantDeclarations`, and the caller feeds them in like any other declaration.
26
+ import { transform } from 'lightningcss';
27
+ import { PROPERTY_TABLE, mapCSSProperty } from "../properties.js";
28
+ import { REM_TO_PX, warnOnce } from "../values.js";
29
+ //#region warnings
30
+ // Warn-once bookkeeping is keyed by FILE because the public entry point takes one declaration at a
31
+ // time — there is no per-run object to hang a `Set` off the way `compileCssToRules` does per call.
32
+ // A Metro watch process therefore warns once per (file, property) for the life of the process,
33
+ // which is the intended granularity: the same bad property in the same file is one authoring
34
+ // mistake, however many times it is recompiled.
35
+ const warnedByFile = new Map();
36
+ function warnedKeysFor(filename) {
37
+ const existing = warnedByFile.get(filename);
38
+ if (existing)
39
+ return existing;
40
+ const created = new Set();
41
+ warnedByFile.set(filename, created);
42
+ return created;
43
+ }
44
+ function warnDrop(context, key, reason) {
45
+ warnOnce(warnedKeysFor(context.filename), key, `[@symbiote-native/css-parser] ${reason}, dropped`);
46
+ }
47
+ //#endregion warnings
48
+ //#region unknown-value readers
49
+ function isRecord(value) {
50
+ return typeof value === 'object' && value !== null;
51
+ }
52
+ function readNumber(value) {
53
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
54
+ }
55
+ function readString(value) {
56
+ return typeof value === 'string' ? value : null;
57
+ }
58
+ function readType(value) {
59
+ return isRecord(value) ? readString(value.type) : null;
60
+ }
61
+ // lightningcss stores lengths, alphas and ratios as f32, so `1.4` comes back as
62
+ // 1.399999976158142. Seven significant digits is the round-trip precision of f32 — it restores the
63
+ // authored decimal without inventing one.
64
+ function roundFloat(value) {
65
+ return Number(value.toPrecision(7));
66
+ }
67
+ //#endregion unknown-value readers
68
+ //#region lengths
69
+ const ABSOLUTE_UNIT_TO_PX = {
70
+ px: 1,
71
+ in: 96,
72
+ cm: 96 / 2.54,
73
+ mm: 96 / 25.4,
74
+ q: 96 / 101.6,
75
+ pt: 96 / 72,
76
+ pc: 16,
77
+ };
78
+ // RN's `borderWidth` takes a number, so the CSS keywords have to become the browser-default points.
79
+ const BORDER_WIDTH_KEYWORD_PX = {
80
+ thin: 1,
81
+ medium: 3,
82
+ thick: 5,
83
+ };
84
+ function unitToPx(unit, amount, context) {
85
+ // `em` is scaled by the root size exactly as the old parser does: this package resolves styles
86
+ // without a box tree, so there is no parent font size to scale against.
87
+ if (unit === 'rem' || unit === 'em') {
88
+ return amount * (context.remToPx ?? REM_TO_PX);
89
+ }
90
+ const factor = ABSOLUTE_UNIT_TO_PX[unit];
91
+ return factor === undefined ? null : amount * factor;
92
+ }
93
+ /**
94
+ * Reduce any of lightningcss's length-ish value wrappers to px / percent / auto, or `null` when RN
95
+ * cannot express it (a viewport unit, an unevaluable `calc()`, `normal`).
96
+ *
97
+ * The wrappers nest differently per property — `font-size` is `{length:{dimension:{unit,value}}}`,
98
+ * `letter-spacing` is `{length:{value:{unit,value}}}`, `padding-top` is
99
+ * `{length-percentage:{dimension:{unit,value}}}` — so this walks the wrappers instead of encoding
100
+ * one shape per property.
101
+ */
102
+ function readLength(value, context) {
103
+ if (!isRecord(value))
104
+ return null;
105
+ // A bare `LengthValue` (`{unit, value}`) — the leaf every wrapper bottoms out at.
106
+ const unit = readString(value.unit);
107
+ const amount = readNumber(value.value);
108
+ if (unit !== null && amount !== null) {
109
+ const px = unitToPx(unit, amount, context);
110
+ return px === null ? null : { kind: 'px', value: px };
111
+ }
112
+ switch (value.type) {
113
+ case 'auto':
114
+ return { kind: 'auto', value: 0 };
115
+ case 'percentage': {
116
+ const fraction = readNumber(value.value);
117
+ // lightningcss reports a percentage as a FRACTION: `100%` arrives as 1.
118
+ return fraction === null
119
+ ? null
120
+ : { kind: 'percent', value: fraction * 100 };
121
+ }
122
+ // RN's `lineHeight` has no multiplier form, so a unitless `line-height: 1.4` becomes 1.4
123
+ // points — wrong, but it is what the retired text pass already shipped and not this file's fix.
124
+ case 'number':
125
+ case 'integer': {
126
+ const plain = readNumber(value.value);
127
+ return plain === null ? null : { kind: 'px', value: plain };
128
+ }
129
+ case 'calc':
130
+ return evaluateCalc(value.value, context);
131
+ case 'length':
132
+ case 'length-percentage':
133
+ case 'dimension':
134
+ case 'value':
135
+ return readLength(value.value, context);
136
+ default: {
137
+ const keyword = readString(value.type);
138
+ const px = keyword === null ? undefined : BORDER_WIDTH_KEYWORD_PX[keyword];
139
+ return px === undefined ? null : { kind: 'px', value: px };
140
+ }
141
+ }
142
+ }
143
+ function lengthToStyleValue(length) {
144
+ switch (length.kind) {
145
+ case 'auto':
146
+ return 'auto';
147
+ case 'percent':
148
+ return `${roundFloat(length.value)}%`;
149
+ case 'px':
150
+ return roundFloat(length.value);
151
+ }
152
+ }
153
+ //#endregion lengths
154
+ //#region calc()
155
+ /**
156
+ * Evaluate a `calc()` sum when EVERY operand reduces to px; return `null` otherwise.
157
+ *
158
+ * lightningcss already folds a same-unit expression before we see it (`calc(10px + 4px)` arrives
159
+ * as `14px`, `calc(2rem * 2)` as `4rem`), so what reaches here is a mixed expression: either mixed
160
+ * LENGTHS (`calc(1rem + 2px)` — evaluable once rem is in px) or a length mixed with a percentage /
161
+ * viewport unit (`calc(100% - 24px)` — RN has no expression form, so the caller drops it).
162
+ */
163
+ function evaluateCalc(node, context) {
164
+ if (!isRecord(node))
165
+ return null;
166
+ switch (node.type) {
167
+ case 'function':
168
+ case 'calc':
169
+ return evaluateCalc(node.value, context);
170
+ case 'sum': {
171
+ if (!Array.isArray(node.value))
172
+ return null;
173
+ let total = 0;
174
+ for (const operand of node.value) {
175
+ const term = evaluateCalc(operand, context);
176
+ if (term === null || term.kind !== 'px')
177
+ return null;
178
+ total += term.value;
179
+ }
180
+ return { kind: 'px', value: total };
181
+ }
182
+ case 'value':
183
+ return readLength(node.value, context);
184
+ // `min()` / `max()` / `clamp()` / `product` — no RN equivalent, and guessing one is how the
185
+ // old evaluator ended up emitting 100 for `calc(100% - 24px)`.
186
+ default:
187
+ return null;
188
+ }
189
+ }
190
+ //#endregion calc()
191
+ //#region colors
192
+ function toHexChannel(channel) {
193
+ const clamped = Math.min(255, Math.max(0, Math.round(channel)));
194
+ return clamped.toString(16).padStart(2, '0');
195
+ }
196
+ // `rgba(0, 0, 0, .3)` survives lightningcss as alpha 0.3019607961177826 — the f32 of 77/255, since
197
+ // alpha is stored 8-bit. Print the shortest decimal that quantizes back to the same byte, so the
198
+ // output reads like the authored value (`0.3`) rather than its rounding noise.
199
+ function shortestAlpha(alpha) {
200
+ const byte = Math.round(alpha * 255);
201
+ for (let digits = 1; digits <= 3; digits++) {
202
+ const candidate = Number(alpha.toFixed(digits));
203
+ if (Math.round(candidate * 255) === byte)
204
+ return candidate;
205
+ }
206
+ return roundFloat(alpha);
207
+ }
208
+ /**
209
+ * A lightningcss `CssColor` → the string shape Fabric is already shipped: a hex when opaque
210
+ * (`#000`, `#0f1e30`), `rgba(r, g, b, a)` when translucent.
211
+ *
212
+ * lightningcss normalizes every legacy notation (`red`, `#000`, `hsl(...)`) to `rgb`, so a keyword
213
+ * comes out as its hex — RN's `processColor` reads both. A wide-gamut color (`oklch`, `lab`) has no
214
+ * RN equivalent at all and returns `null` so the caller drops it with a warning.
215
+ */
216
+ function colorToCss(value) {
217
+ if (!isRecord(value))
218
+ return null;
219
+ if (value.type === 'currentcolor')
220
+ return 'currentColor';
221
+ if (value.type !== 'rgb')
222
+ return null;
223
+ const r = readNumber(value.r);
224
+ const g = readNumber(value.g);
225
+ const b = readNumber(value.b);
226
+ const alpha = readNumber(value.alpha);
227
+ if (r === null || g === null || b === null || alpha === null)
228
+ return null;
229
+ if (alpha < 1) {
230
+ return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${shortestAlpha(alpha)})`;
231
+ }
232
+ const hex = `${toHexChannel(r)}${toHexChannel(g)}${toHexChannel(b)}`;
233
+ const isShortenable = hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5];
234
+ return isShortenable ? `#${hex[0]}${hex[2]}${hex[4]}` : `#${hex}`;
235
+ }
236
+ //#endregion colors
237
+ //#region serializing a declaration back to CSS text
238
+ const SERIALIZER_TEMPLATE = new TextEncoder().encode('.s{color:red}');
239
+ const SERIALIZER_DECODER = new TextDecoder();
240
+ /**
241
+ * The CSS text of one declaration's VALUE, produced by lightningcss's own printer.
242
+ *
243
+ * Needed for the `raw` property kind — `transform`, `filter`, `background-image`,
244
+ * `box-shadow`, `transform-origin` are passed to the engine as unparsed CSS text on purpose (see
245
+ * the PROPERTY_TABLE comments: RN's own JS processors parse that exact syntax at commit time). We
246
+ * hold their typed AST and need text back, and re-printing a gradient / transform-list / filter
247
+ * chain by hand would be a second, narrower serializer that drifts from lightningcss's.
248
+ *
249
+ * The trick is to hand lightningcss a one-rule stylesheet and swap that rule's declarations for
250
+ * ours, then read the value out of the printed output.
251
+ */
252
+ function serializeValue(declaration) {
253
+ try {
254
+ const result = transform({
255
+ filename: 'symbiote-declaration.css',
256
+ code: SERIALIZER_TEMPLATE,
257
+ visitor: {
258
+ Rule(rule) {
259
+ if (rule.type !== 'style')
260
+ return;
261
+ rule.value.declarations = {
262
+ declarations: [declaration],
263
+ importantDeclarations: [],
264
+ };
265
+ return rule;
266
+ },
267
+ },
268
+ });
269
+ const css = SERIALIZER_DECODER.decode(result.code);
270
+ const open = css.indexOf('{');
271
+ const close = css.lastIndexOf('}');
272
+ if (open === -1 || close <= open)
273
+ return null;
274
+ const body = css
275
+ .slice(open + 1, close)
276
+ .trim()
277
+ .replace(/;$/, '');
278
+ const colon = body.indexOf(':');
279
+ return colon === -1 ? null : body.slice(colon + 1).trim();
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ }
285
+ //#endregion serializing a declaration back to CSS text
286
+ //#region var() substitution
287
+ const TOKEN_TEXT = {
288
+ colon: ':',
289
+ semicolon: ';',
290
+ comma: ',',
291
+ 'parenthesis-block': '(',
292
+ 'square-bracket-block': '[',
293
+ 'curly-bracket-block': '{',
294
+ 'close-parenthesis': ')',
295
+ 'close-square-bracket': ']',
296
+ 'close-curly-bracket': '}',
297
+ comment: '',
298
+ };
299
+ function rawTokenToText(token) {
300
+ if (!isRecord(token))
301
+ return null;
302
+ const type = readString(token.type);
303
+ if (type === null)
304
+ return null;
305
+ const fixed = TOKEN_TEXT[type];
306
+ if (fixed !== undefined)
307
+ return fixed;
308
+ const text = readString(token.value);
309
+ const amount = readNumber(token.value);
310
+ switch (type) {
311
+ case 'ident':
312
+ case 'delim':
313
+ case 'white-space':
314
+ return text;
315
+ case 'at-keyword':
316
+ return text === null ? null : `@${text}`;
317
+ case 'hash':
318
+ case 'id-hash':
319
+ return text === null ? null : `#${text}`;
320
+ case 'string':
321
+ return text === null ? null : JSON.stringify(text);
322
+ case 'unquoted-url':
323
+ return text === null ? null : `url(${text})`;
324
+ case 'function':
325
+ return text === null ? null : `${text}(`;
326
+ case 'number':
327
+ return amount === null ? null : String(roundFloat(amount));
328
+ case 'percentage':
329
+ return amount === null ? null : `${roundFloat(amount * 100)}%`;
330
+ case 'dimension': {
331
+ const unit = readString(token.unit);
332
+ return amount === null || unit === null
333
+ ? null
334
+ : `${roundFloat(amount)}${unit}`;
335
+ }
336
+ default:
337
+ return null;
338
+ }
339
+ }
340
+ /**
341
+ * Print a token list (an `unparsed` declaration's value, or a custom property's) back to CSS text.
342
+ *
343
+ * In `substitute` mode every `var(--x)` is replaced from the printer's variable map, falling back
344
+ * to the `var()` fallback argument when the name is undeclared; a name that resolves to nothing is
345
+ * pushed onto `missing` so the caller can drop the declaration and say WHICH custom property was
346
+ * never declared. In `keep` mode the `var()` is printed verbatim.
347
+ */
348
+ function tokensToText(tokens, printer) {
349
+ if (!Array.isArray(tokens))
350
+ return null;
351
+ let text = '';
352
+ for (const token of tokens) {
353
+ const piece = tokenOrValueToText(token, printer);
354
+ if (piece === null)
355
+ return null;
356
+ text += piece;
357
+ }
358
+ return text;
359
+ }
360
+ function tokenOrValueToText(node, printer) {
361
+ if (!isRecord(node))
362
+ return null;
363
+ switch (node.type) {
364
+ case 'token':
365
+ return rawTokenToText(node.value);
366
+ case 'color':
367
+ return colorToCss(node.value);
368
+ case 'dashed-ident':
369
+ return readString(node.value);
370
+ case 'length':
371
+ case 'angle':
372
+ case 'time':
373
+ case 'resolution': {
374
+ const dimension = isRecord(node.value) ? node.value : null;
375
+ if (dimension === null)
376
+ return null;
377
+ const unit = readString(dimension.unit) ?? readString(dimension.type);
378
+ const amount = readNumber(dimension.value);
379
+ return unit === null || amount === null
380
+ ? null
381
+ : `${roundFloat(amount)}${unit}`;
382
+ }
383
+ case 'function': {
384
+ if (!isRecord(node.value))
385
+ return null;
386
+ const name = readString(node.value.name);
387
+ const args = tokensToText(node.value.arguments, printer);
388
+ return name === null || args === null ? null : `${name}(${args})`;
389
+ }
390
+ case 'var':
391
+ return variableToText(node.value, printer);
392
+ // `url()` / `env()` / an animation name have no place in an RN style value.
393
+ default:
394
+ return null;
395
+ }
396
+ }
397
+ function variableToText(variable, printer) {
398
+ if (!isRecord(variable))
399
+ return null;
400
+ const name = isRecord(variable.name) ? readString(variable.name.ident) : null;
401
+ if (name === null)
402
+ return null;
403
+ const hasFallback = variable.fallback !== undefined && variable.fallback !== null;
404
+ if (printer.varMode === 'keep') {
405
+ if (!hasFallback)
406
+ return `var(${name})`;
407
+ const fallback = tokensToText(variable.fallback, printer);
408
+ return fallback === null ? null : `var(${name}, ${fallback})`;
409
+ }
410
+ const declared = printer.variables.get(name);
411
+ if (declared !== undefined)
412
+ return declared;
413
+ if (hasFallback)
414
+ return tokensToText(variable.fallback, printer);
415
+ // Declared in ANOTHER file — the documented trap this pipeline exists to stop shipping. The
416
+ // registry is per-file, so there is nothing to resolve against and no value worth emitting.
417
+ printer.missing.push(name);
418
+ return null;
419
+ }
420
+ // Every one of these arrives from lightningcss PRE-EXPANDED into a Rect, which is what makes the
421
+ // old parser's `padding: 8px 16px -> { padding: 8 }` fixable at all.
422
+ const RECT_PROPERTIES = {
423
+ padding: {
424
+ shorthand: 'padding',
425
+ sides: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
426
+ read: 'length',
427
+ },
428
+ margin: {
429
+ shorthand: 'margin',
430
+ sides: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
431
+ read: 'length',
432
+ },
433
+ 'border-width': {
434
+ shorthand: 'borderWidth',
435
+ sides: [
436
+ 'borderTopWidth',
437
+ 'borderRightWidth',
438
+ 'borderBottomWidth',
439
+ 'borderLeftWidth',
440
+ ],
441
+ read: 'length',
442
+ },
443
+ 'border-color': {
444
+ shorthand: 'borderColor',
445
+ sides: [
446
+ 'borderTopColor',
447
+ 'borderRightColor',
448
+ 'borderBottomColor',
449
+ 'borderLeftColor',
450
+ ],
451
+ read: 'color',
452
+ },
453
+ // RN has `borderStyle` and no per-side style prop, so a four-value `border-style` is
454
+ // inexpressible rather than expandable.
455
+ 'border-style': { shorthand: 'borderStyle', sides: null, read: 'keyword' },
456
+ // Always expanded: `top`/`right`/`bottom`/`left` are the props every RN version has, whereas the
457
+ // `inset` shorthand is newer — expanding costs three keys and can never be wrong.
458
+ inset: {
459
+ shorthand: null,
460
+ sides: ['top', 'right', 'bottom', 'left'],
461
+ read: 'length',
462
+ },
463
+ };
464
+ const RECT_KEYS = ['top', 'right', 'bottom', 'left'];
465
+ const CORNER_KEYS = [
466
+ 'topLeft',
467
+ 'topRight',
468
+ 'bottomRight',
469
+ 'bottomLeft',
470
+ ];
471
+ const CORNER_PROPS = [
472
+ 'borderTopLeftRadius',
473
+ 'borderTopRightRadius',
474
+ 'borderBottomRightRadius',
475
+ 'borderBottomLeftRadius',
476
+ ];
477
+ // A corner LONGHAND never arrives as a bare length: lightningcss gives a Size2D pair, so
478
+ // `border-top-left-radius: 20px` is `[20px, 20px]` and `4px 8px` is `[4px, 8px]`. Reading it as
479
+ // one length made every corner longhand drop with "cannot express" on a plain `20px` — found in
480
+ // `examples/solid/App.css`'s `.sheet`, the only place in the corpus that uses them.
481
+ const CORNER_RADIUS_PROPERTIES = new Set([
482
+ 'border-top-left-radius',
483
+ 'border-top-right-radius',
484
+ 'border-bottom-right-radius',
485
+ 'border-bottom-left-radius',
486
+ ]);
487
+ function readRectSide(side, mapping, context) {
488
+ switch (mapping.read) {
489
+ case 'length': {
490
+ const length = readLength(side, context);
491
+ return length === null ? null : lengthToStyleValue(length);
492
+ }
493
+ case 'color':
494
+ return colorToCss(side);
495
+ case 'keyword':
496
+ return readString(side);
497
+ }
498
+ }
499
+ function rectToStyle(cssProperty, value, mapping, context) {
500
+ if (!isRecord(value))
501
+ return {};
502
+ const resolved = [];
503
+ for (const key of RECT_KEYS) {
504
+ const side = readRectSide(value[key], mapping, context);
505
+ if (side === null) {
506
+ warnDrop(context, cssProperty, `"${cssProperty}" has a value React Native cannot express`);
507
+ return {};
508
+ }
509
+ resolved.push(side);
510
+ }
511
+ const uniform = resolved.every(side => side === resolved[0]);
512
+ if (uniform && mapping.shorthand !== null) {
513
+ return { [mapping.shorthand]: resolved[0] };
514
+ }
515
+ if (mapping.sides === null) {
516
+ warnDrop(context, cssProperty, `"${cssProperty}" differs per side and React Native has only the "${mapping.shorthand}" shorthand`);
517
+ return {};
518
+ }
519
+ const style = {};
520
+ mapping.sides.forEach((prop, index) => {
521
+ style[prop] = resolved[index];
522
+ });
523
+ return style;
524
+ }
525
+ function borderRadiusToStyle(value, context) {
526
+ if (!isRecord(value))
527
+ return {};
528
+ const resolved = [];
529
+ for (const key of CORNER_KEYS) {
530
+ const corner = value[key];
531
+ // Each corner is an [x, y] pair; RN has no elliptical radius, so only x survives.
532
+ const radius = Array.isArray(corner) ? corner[0] : corner;
533
+ const length = readLength(radius, context);
534
+ if (length === null) {
535
+ warnDrop(context, 'border-radius', '"border-radius" has a value React Native cannot express');
536
+ return {};
537
+ }
538
+ resolved.push(lengthToStyleValue(length));
539
+ }
540
+ if (resolved.every(corner => corner === resolved[0])) {
541
+ return { borderRadius: resolved[0] };
542
+ }
543
+ const style = {};
544
+ CORNER_PROPS.forEach((prop, index) => {
545
+ style[prop] = resolved[index];
546
+ });
547
+ return style;
548
+ }
549
+ function gapToStyle(value, context) {
550
+ if (!isRecord(value))
551
+ return {};
552
+ // `gap: normal` IS the initial value; dropping it changes nothing, so it stays silent.
553
+ const row = readLength(value.row, context);
554
+ const column = readLength(value.column, context);
555
+ if (row === null || column === null)
556
+ return {};
557
+ const rowValue = lengthToStyleValue(row);
558
+ const columnValue = lengthToStyleValue(column);
559
+ return rowValue === columnValue
560
+ ? { gap: rowValue }
561
+ : { rowGap: rowValue, columnGap: columnValue };
562
+ }
563
+ function flexToStyle(value, context) {
564
+ if (!isRecord(value))
565
+ return {};
566
+ const grow = readNumber(value.grow);
567
+ const shrink = readNumber(value.shrink);
568
+ if (grow === null || shrink === null)
569
+ return {};
570
+ const basis = readLength(value.basis, context);
571
+ const isRnFlexShorthand = shrink === 1 &&
572
+ basis !== null &&
573
+ basis.kind === 'percent' &&
574
+ basis.value === 0;
575
+ // RN's `flex: n` means exactly `n 1 0` — the CSS `flex: <number>` expansion — so the common case
576
+ // stays the one key the old parser emitted.
577
+ if (isRnFlexShorthand)
578
+ return { flex: roundFloat(grow) };
579
+ const style = {
580
+ flexGrow: roundFloat(grow),
581
+ flexShrink: roundFloat(shrink),
582
+ };
583
+ if (basis !== null)
584
+ style.flexBasis = lengthToStyleValue(basis);
585
+ return style;
586
+ }
587
+ // `text-shadow` is the one property with no engine-side processor to defer to: RN never got a
588
+ // unified CSS-string `textShadow` prop, only the three decomposed legacy props. So this package
589
+ // is the only place that CAN decompose it. The output shape (three keys, a `{width, height}`
590
+ // offset object, `#000000` when the author wrote no color) is the one the registry has always
591
+ // been fed, carried over from the retired text pass this replaced.
592
+ const TEXT_SHADOW_DEFAULT_COLOR = '#000000';
593
+ function shadowLengthPx(value, context) {
594
+ const length = readLength(value, context);
595
+ return length === null || length.kind !== 'px'
596
+ ? null
597
+ : roundFloat(length.value);
598
+ }
599
+ function textShadowToStyle(value, context) {
600
+ if (!Array.isArray(value))
601
+ return {};
602
+ if (value.length > 1) {
603
+ // Same key and wording as ../values.ts, so a stylesheet compiled by either pipeline reports
604
+ // the limitation identically.
605
+ warnOnce(warnedKeysFor(context.filename), 'text-shadow:multiple', '[@symbiote-native/css-parser] multiple text-shadow layers are not supported, only the first is applied');
606
+ }
607
+ const layer = value[0];
608
+ if (!isRecord(layer))
609
+ return {};
610
+ const width = shadowLengthPx(layer.xOffset, context);
611
+ const height = shadowLengthPx(layer.yOffset, context);
612
+ if (width === null || height === null) {
613
+ warnDrop(context, 'text-shadow', '"text-shadow" has offsets React Native cannot express');
614
+ return {};
615
+ }
616
+ // CSS defaults an omitted shadow color to `currentColor`, which RN has no concept of; the text
617
+ // parser lands on black for the same reason, so both pipelines agree.
618
+ const color = colorToCss(layer.color);
619
+ const isUsableColor = color !== null && color !== 'currentColor';
620
+ return {
621
+ textShadowColor: isUsableColor ? color : TEXT_SHADOW_DEFAULT_COLOR,
622
+ textShadowOffset: { width, height },
623
+ textShadowRadius: shadowLengthPx(layer.blur, context) ?? 0,
624
+ };
625
+ }
626
+ //#endregion shorthand (Rect / corner / axis) expansion
627
+ //#region entry point
628
+ // A `var()` may resolve to text holding another `var()`; each round re-parses, so cap the chain
629
+ // rather than trusting authored CSS to terminate it.
630
+ const MAX_SUBSTITUTION_DEPTH = 4;
631
+ const INITIAL_VALUE_KEYWORDS = new Set(['normal', 'none']);
632
+ // Single-color properties. `border-color` is absent on purpose — it arrives as a Rect and is
633
+ // handled by {@link RECT_PROPERTIES}, which reads each side with the same {@link colorToCss}.
634
+ const COLOR_PROPERTIES = new Set([
635
+ 'color',
636
+ 'background-color',
637
+ 'border-top-color',
638
+ 'border-right-color',
639
+ 'border-bottom-color',
640
+ 'border-left-color',
641
+ ]);
642
+ function isDeclaration(value) {
643
+ return isRecord(value) && typeof value.property === 'string';
644
+ }
645
+ /** Keep only the scalar entries `mapCSSProperty` produced — {@link IStyleObject} carries no objects. */
646
+ function toStyleObject(mapped) {
647
+ const style = {};
648
+ if (mapped === null)
649
+ return style;
650
+ for (const [prop, value] of Object.entries(mapped)) {
651
+ if (typeof value === 'string' || typeof value === 'number') {
652
+ style[prop] = value;
653
+ }
654
+ }
655
+ return style;
656
+ }
657
+ /** Re-parse one substituted declaration back through lightningcss. */
658
+ function parseDeclarationText(cssProperty, valueText) {
659
+ const parsed = [];
660
+ try {
661
+ transform({
662
+ filename: 'symbiote-substituted.css',
663
+ code: new TextEncoder().encode(`.s{${cssProperty}:${valueText}}`),
664
+ visitor: {
665
+ Rule(rule) {
666
+ if (rule.type !== 'style')
667
+ return;
668
+ parsed.push(...rule.value.declarations.declarations, ...rule.value.declarations.importantDeclarations);
669
+ },
670
+ },
671
+ });
672
+ }
673
+ catch {
674
+ return [];
675
+ }
676
+ return parsed;
677
+ }
678
+ function unparsedToStyle(value, context, depth) {
679
+ const cssProperty = value.propertyId.property;
680
+ // An unparsed CUSTOM property is still just a `--x` declaration — the caller's pre-pass owns it.
681
+ if (cssProperty === 'custom')
682
+ return {};
683
+ const missing = [];
684
+ const valueText = tokensToText(value.value, {
685
+ variables: context.variables,
686
+ varMode: 'substitute',
687
+ missing,
688
+ });
689
+ if (missing.length > 0) {
690
+ warnDrop(context, `${cssProperty}:var`, `custom ${missing.length === 1 ? 'property' : 'properties'} ${missing.map(name => `"${name}"`).join(', ')} used by "${cssProperty}" ${missing.length === 1 ? 'is' : 'are'} not declared in this file`);
691
+ return {};
692
+ }
693
+ if (valueText === null || depth >= MAX_SUBSTITUTION_DEPTH) {
694
+ warnDrop(context, cssProperty, `"${cssProperty}" has a value React Native cannot express`);
695
+ return {};
696
+ }
697
+ const style = {};
698
+ for (const parsed of parseDeclarationText(cssProperty, valueText)) {
699
+ Object.assign(style, declarationToStyleAt(parsed, context, depth + 1));
700
+ }
701
+ return style;
702
+ }
703
+ /**
704
+ * One lightningcss `Declaration` (the object handed to a visitor / found in
705
+ * `rule.value.declarations.declarations`) mapped to zero or more RN style entries.
706
+ */
707
+ export function declarationToStyle(declaration, context) {
708
+ return declarationToStyleAt(declaration, context, 0);
709
+ }
710
+ function declarationToStyleAt(declaration, context, depth) {
711
+ if (!isDeclaration(declaration))
712
+ return {};
713
+ if (declaration.property === 'custom') {
714
+ const name = declaration.value.name;
715
+ // A `--x` declaration: the caller's pre-pass collected it into `context.variables`.
716
+ if (name.startsWith('--'))
717
+ return {};
718
+ // lightningcss files a property IT does not know under `custom` too, so this is where a
719
+ // genuine typo (`colr: red`) surfaces. `mapCSSProperty` owns the drop-warning wording — the
720
+ // value argument is unread on that path, it warns and returns null on the table miss.
721
+ mapCSSProperty(name, '', warnedKeysFor(context.filename));
722
+ return {};
723
+ }
724
+ if (declaration.property === 'unparsed') {
725
+ return unparsedToStyle(declaration.value, context, depth);
726
+ }
727
+ const cssProperty = declaration.property;
728
+ const value = declaration.value;
729
+ const rect = RECT_PROPERTIES[cssProperty];
730
+ if (rect)
731
+ return rectToStyle(cssProperty, value, rect, context);
732
+ switch (cssProperty) {
733
+ case 'border-radius':
734
+ return borderRadiusToStyle(value, context);
735
+ case 'gap':
736
+ return gapToStyle(value, context);
737
+ case 'flex':
738
+ return flexToStyle(value, context);
739
+ case 'overflow': {
740
+ // RN has one `overflow`; a differing x/y is inexpressible.
741
+ const x = isRecord(value) ? readString(value.x) : null;
742
+ const y = isRecord(value) ? readString(value.y) : null;
743
+ if (x === null || x !== y) {
744
+ warnDrop(context, cssProperty, '"overflow" differs per axis and React Native has one overflow prop');
745
+ return {};
746
+ }
747
+ return { overflow: x };
748
+ }
749
+ case 'aspect-ratio': {
750
+ const ratio = isRecord(value) ? value.ratio : null;
751
+ const width = Array.isArray(ratio) ? readNumber(ratio[0]) : null;
752
+ const height = Array.isArray(ratio) ? readNumber(ratio[1]) : null;
753
+ if (width === null || height === null || height === 0)
754
+ return {};
755
+ return { aspectRatio: roundFloat(width / height) };
756
+ }
757
+ case 'text-shadow':
758
+ return textShadowToStyle(value, context);
759
+ // A CSS-Modules DIRECTIVE, not a style property — and lightningcss has ALREADY acted on it:
760
+ // the composed names come back through `exports[...].composes`, which
761
+ // `metro-css-module/index.ts` walks to flatten a chain. Measured 2026-08-20: it arrives as a
762
+ // first-class `property: 'composes'`, NOT under `custom` where an unknown name goes, so it fell
763
+ // through to the PROPERTY_TABLE miss and every author of a WORKING `.module.*` file was told
764
+ // "unsupported CSS property" on every build. The drop warnings are the only signal that a real
765
+ // rule died; a channel that cries wolf on working code is the one nobody reads when it matters.
766
+ case 'composes':
767
+ return {};
768
+ default:
769
+ break;
770
+ }
771
+ const mapping = PROPERTY_TABLE[cssProperty];
772
+ if (!mapping) {
773
+ mapCSSProperty(cssProperty, '', warnedKeysFor(context.filename));
774
+ return {};
775
+ }
776
+ // Colors are `raw` in the table but must NOT go through the printer below: lightningcss prints
777
+ // a translucent color as `#0000004d`, and the shape this pipeline ships is `rgba(0, 0, 0, 0.3)`.
778
+ if (COLOR_PROPERTIES.has(cssProperty)) {
779
+ const color = colorToCss(value);
780
+ if (color === null) {
781
+ warnDrop(context, cssProperty, `"${cssProperty}" uses a color space React Native cannot read`);
782
+ return {};
783
+ }
784
+ return { [mapping.rnProperty]: color };
785
+ }
786
+ // Only the horizontal half survives — RN has no elliptical radius, the same rule
787
+ // `borderRadiusToStyle` already applies to each corner of the shorthand.
788
+ if (CORNER_RADIUS_PROPERTIES.has(cssProperty)) {
789
+ const radius = readLength(Array.isArray(value) ? value[0] : value, context);
790
+ if (radius === null) {
791
+ warnDrop(context, cssProperty, `"${cssProperty}" has a value React Native cannot express`);
792
+ return {};
793
+ }
794
+ return { [mapping.rnProperty]: lengthToStyleValue(radius) };
795
+ }
796
+ if (mapping.kind === 'raw') {
797
+ const text = serializeValue(declaration);
798
+ if (text === null) {
799
+ warnDrop(context, cssProperty, `"${cssProperty}" has a value React Native cannot express`);
800
+ return {};
801
+ }
802
+ return toStyleObject(mapCSSProperty(cssProperty, text, warnedKeysFor(context.filename)));
803
+ }
804
+ if (mapping.kind === 'number') {
805
+ const plain = readNumber(value) ?? (isRecord(value) ? readNumber(value.value) : null);
806
+ if (plain === null)
807
+ return {};
808
+ return { [mapping.rnProperty]: roundFloat(plain) };
809
+ }
810
+ const length = readLength(value, context);
811
+ if (length === null) {
812
+ // `normal` / `none` ARE the CSS initial values (`letter-spacing`, `line-height`) — dropping
813
+ // one changes nothing on screen, so it never earns a warning.
814
+ if (INITIAL_VALUE_KEYWORDS.has(readType(value) ?? ''))
815
+ return {};
816
+ // The `calc()` that survived lightningcss's own folding mixes a percentage or a viewport unit
817
+ // with a length — RN has no expression form, and the old parser's silent `100` for
818
+ // `calc(100% - 24px)` is exactly the bug this drop replaces.
819
+ const reason = readType(isRecord(value) ? value.value : null) === 'calc'
820
+ ? `"${cssProperty}" uses a calc() mixing units React Native cannot resolve`
821
+ : `"${cssProperty}" has a value React Native cannot express`;
822
+ warnDrop(context, cssProperty, reason);
823
+ return {};
824
+ }
825
+ return { [mapping.rnProperty]: lengthToStyleValue(length) };
826
+ }
827
+ //#endregion entry point
828
+ //#region file-wide custom-property pre-pass
829
+ /**
830
+ * Every custom property (`--x`) declared anywhere in the file, name -> its raw value text, in the
831
+ * shape {@link IDeclarationContext.variables} expects.
832
+ *
833
+ * A pass of its OWN, ahead of mapping declarations, because nothing orders `:root` first — a
834
+ * component stylesheet may declare a token in a class rule, inside `@media`, or below its first
835
+ * use, and a single forward walk would miss it. Later declaration of the same name wins, which is
836
+ * what the cascade does within one file.
837
+ *
838
+ * The value is stored VERBATIM: a token list that is itself a `var()` chain is printed back as
839
+ * `var(--other)`, and resolution stays in {@link declarationToStyle}, which is the only place that
840
+ * knows whether the chain terminates. Serialization goes through the same token printer the
841
+ * `unparsed` path uses — this package keeps ONE printer.
842
+ */
843
+ export function variablesIn(css, filename) {
844
+ const variables = new Map();
845
+ if (!css)
846
+ return variables;
847
+ const printer = {
848
+ variables,
849
+ varMode: 'keep',
850
+ missing: [],
851
+ };
852
+ try {
853
+ transform({
854
+ filename,
855
+ code: new TextEncoder().encode(css),
856
+ // A malformed rule elsewhere in the file must not cost us the tokens that ARE declared.
857
+ errorRecovery: true,
858
+ visitor: {
859
+ Rule(rule) {
860
+ if (rule.type !== 'style')
861
+ return;
862
+ const block = rule.value.declarations;
863
+ for (const declaration of [
864
+ ...block.declarations,
865
+ ...block.importantDeclarations,
866
+ ]) {
867
+ if (declaration.property !== 'custom')
868
+ continue;
869
+ const name = declaration.value.name;
870
+ if (!name.startsWith('--'))
871
+ continue;
872
+ const text = tokensToText(declaration.value.value, printer);
873
+ if (text !== null)
874
+ variables.set(name, text.trim());
875
+ }
876
+ },
877
+ },
878
+ });
879
+ }
880
+ catch {
881
+ return variables;
882
+ }
883
+ return variables;
884
+ }
885
+ //#endregion file-wide custom-property pre-pass