rnwind 0.0.8 → 0.0.10
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/lib/cjs/core/parser/color.cjs +33 -1
- package/lib/cjs/core/parser/color.cjs.map +1 -1
- package/lib/cjs/core/parser/color.d.ts +10 -0
- package/lib/cjs/core/parser/declaration.cjs +121 -9
- package/lib/cjs/core/parser/declaration.cjs.map +1 -1
- package/lib/cjs/core/parser/gradient.cjs +46 -12
- package/lib/cjs/core/parser/gradient.cjs.map +1 -1
- package/lib/cjs/core/parser/gradient.d.ts +2 -1
- package/lib/cjs/core/parser/keyframes.cjs +27 -12
- package/lib/cjs/core/parser/keyframes.cjs.map +1 -1
- package/lib/cjs/core/parser/keyframes.d.ts +11 -0
- package/lib/cjs/core/parser/layout-dispatcher.cjs +33 -10
- package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/length.cjs +17 -1
- package/lib/cjs/core/parser/length.cjs.map +1 -1
- package/lib/cjs/core/parser/safe-area.cjs +24 -3
- package/lib/cjs/core/parser/safe-area.cjs.map +1 -1
- package/lib/cjs/core/parser/theme-vars.cjs +58 -8
- package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
- package/lib/cjs/core/parser/tokens.cjs +77 -9
- package/lib/cjs/core/parser/tokens.cjs.map +1 -1
- package/lib/cjs/core/parser/tokens.d.ts +9 -0
- package/lib/cjs/core/parser/transform.cjs +18 -9
- package/lib/cjs/core/parser/transform.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.cjs +136 -34
- package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.d.ts +20 -0
- package/lib/cjs/core/parser/typography-dispatcher.cjs +19 -1
- package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/typography.cjs +15 -18
- package/lib/cjs/core/parser/typography.cjs.map +1 -1
- package/lib/cjs/core/parser/typography.d.ts +5 -5
- package/lib/cjs/core/style-builder/build-style.cjs +12 -3
- package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
- package/lib/cjs/core/style-builder/build-style.d.ts +3 -1
- package/lib/cjs/core/style-builder/union-builder.cjs +9 -11
- package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
- package/lib/cjs/core/style-builder/union-builder.d.ts +7 -8
- package/lib/cjs/metro/dts.cjs +6 -1
- package/lib/cjs/metro/dts.cjs.map +1 -1
- package/lib/cjs/metro/transformer.cjs +42 -77
- package/lib/cjs/metro/transformer.cjs.map +1 -1
- package/lib/cjs/metro/with-config.cjs +9 -29
- package/lib/cjs/metro/with-config.cjs.map +1 -1
- package/lib/cjs/runtime/hooks/use-scheme.cjs +17 -11
- package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
- package/lib/cjs/runtime/hooks/use-scheme.d.ts +7 -4
- package/lib/cjs/runtime/index.cjs +2 -1
- package/lib/cjs/runtime/index.cjs.map +1 -1
- package/lib/cjs/runtime/index.d.ts +2 -2
- package/lib/cjs/runtime/lookup-css.cjs +41 -0
- package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
- package/lib/cjs/runtime/lookup-css.d.ts +29 -0
- package/lib/cjs/runtime/resolve.cjs +8 -6
- package/lib/cjs/runtime/resolve.cjs.map +1 -1
- package/lib/cjs/runtime/wrap.cjs +50 -57
- package/lib/cjs/runtime/wrap.cjs.map +1 -1
- package/lib/cjs/runtime/wrap.d.ts +10 -4
- package/lib/cjs/testing/index.cjs +1 -1
- package/lib/cjs/testing/index.cjs.map +1 -1
- package/lib/esm/core/parser/color.d.ts +10 -0
- package/lib/esm/core/parser/color.mjs +34 -3
- package/lib/esm/core/parser/color.mjs.map +1 -1
- package/lib/esm/core/parser/declaration.mjs +122 -10
- package/lib/esm/core/parser/declaration.mjs.map +1 -1
- package/lib/esm/core/parser/gradient.d.ts +2 -1
- package/lib/esm/core/parser/gradient.mjs +45 -11
- package/lib/esm/core/parser/gradient.mjs.map +1 -1
- package/lib/esm/core/parser/keyframes.d.ts +11 -0
- package/lib/esm/core/parser/keyframes.mjs +27 -12
- package/lib/esm/core/parser/keyframes.mjs.map +1 -1
- package/lib/esm/core/parser/layout-dispatcher.mjs +33 -10
- package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/length.mjs +17 -1
- package/lib/esm/core/parser/length.mjs.map +1 -1
- package/lib/esm/core/parser/safe-area.mjs +24 -3
- package/lib/esm/core/parser/safe-area.mjs.map +1 -1
- package/lib/esm/core/parser/theme-vars.mjs +58 -8
- package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
- package/lib/esm/core/parser/tokens.d.ts +9 -0
- package/lib/esm/core/parser/tokens.mjs +77 -10
- package/lib/esm/core/parser/tokens.mjs.map +1 -1
- package/lib/esm/core/parser/transform.mjs +18 -9
- package/lib/esm/core/parser/transform.mjs.map +1 -1
- package/lib/esm/core/parser/tw-parser.d.ts +20 -0
- package/lib/esm/core/parser/tw-parser.mjs +138 -36
- package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs +19 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/typography.d.ts +5 -5
- package/lib/esm/core/parser/typography.mjs +15 -18
- package/lib/esm/core/parser/typography.mjs.map +1 -1
- package/lib/esm/core/style-builder/build-style.d.ts +3 -1
- package/lib/esm/core/style-builder/build-style.mjs +12 -3
- package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
- package/lib/esm/core/style-builder/union-builder.d.ts +7 -8
- package/lib/esm/core/style-builder/union-builder.mjs +9 -11
- package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
- package/lib/esm/metro/dts.mjs +6 -1
- package/lib/esm/metro/dts.mjs.map +1 -1
- package/lib/esm/metro/transformer.mjs +42 -77
- package/lib/esm/metro/transformer.mjs.map +1 -1
- package/lib/esm/metro/with-config.mjs +10 -30
- package/lib/esm/metro/with-config.mjs.map +1 -1
- package/lib/esm/runtime/hooks/use-scheme.d.ts +7 -4
- package/lib/esm/runtime/hooks/use-scheme.mjs +17 -11
- package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
- package/lib/esm/runtime/index.d.ts +2 -2
- package/lib/esm/runtime/index.mjs +2 -2
- package/lib/esm/runtime/index.mjs.map +1 -1
- package/lib/esm/runtime/lookup-css.d.ts +29 -0
- package/lib/esm/runtime/lookup-css.mjs +39 -1
- package/lib/esm/runtime/lookup-css.mjs.map +1 -1
- package/lib/esm/runtime/resolve.mjs +9 -7
- package/lib/esm/runtime/resolve.mjs.map +1 -1
- package/lib/esm/runtime/wrap.d.ts +10 -4
- package/lib/esm/runtime/wrap.mjs +50 -57
- package/lib/esm/runtime/wrap.mjs.map +1 -1
- package/lib/esm/testing/index.mjs +2 -2
- package/lib/esm/testing/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/parser/color.ts +32 -1
- package/src/core/parser/declaration.ts +119 -10
- package/src/core/parser/gradient.ts +48 -11
- package/src/core/parser/keyframes.ts +31 -3
- package/src/core/parser/layout-dispatcher.ts +32 -9
- package/src/core/parser/length.ts +18 -1
- package/src/core/parser/safe-area.ts +23 -2
- package/src/core/parser/theme-vars.ts +75 -8
- package/src/core/parser/tokens.ts +76 -9
- package/src/core/parser/transform.ts +19 -8
- package/src/core/parser/tw-parser.ts +148 -31
- package/src/core/parser/typography-dispatcher.ts +20 -1
- package/src/core/parser/typography.ts +15 -15
- package/src/core/style-builder/build-style.ts +12 -1
- package/src/core/style-builder/union-builder.ts +10 -12
- package/src/metro/dts.ts +6 -1
- package/src/metro/transformer.ts +42 -78
- package/src/metro/with-config.ts +10 -29
- package/src/runtime/hooks/use-scheme.ts +17 -10
- package/src/runtime/index.ts +2 -1
- package/src/runtime/lookup-css.ts +42 -0
- package/src/runtime/resolve.ts +9 -7
- package/src/runtime/wrap.tsx +57 -61
- package/src/testing/index.ts +3 -0
package/src/core/parser/color.ts
CHANGED
|
@@ -32,7 +32,9 @@ function byteToHex(byte: number): string {
|
|
|
32
32
|
*/
|
|
33
33
|
function rgbIntsToString(r: number, g: number, b: number, alpha: number): string {
|
|
34
34
|
if (alpha >= 1) return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}`
|
|
35
|
-
|
|
35
|
+
// Round the alpha to shed f32 noise (`0.2 → 0.20000000298…`) — RN parses
|
|
36
|
+
// either, but the rounded form keeps generated StyleSheets compact.
|
|
37
|
+
return `rgba(${r}, ${g}, ${b}, ${Math.round(alpha * 10_000) / 10_000})`
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -134,6 +136,35 @@ function xyzToHex(color: { type: 'xyz-d50' | 'xyz-d65'; x: number; y: number; z:
|
|
|
134
136
|
return withAlpha(formatHex({ mode, x: color.x, y: color.y, z: color.z }) ?? null, color.alpha)
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Modern CSS color functions RN's native view manager can't paint —
|
|
141
|
+
* everything else (hex, `rgb()`/`rgba()`, `hsl()`/`hsla()`, named colors,
|
|
142
|
+
* `transparent`, `currentColor`) RN reads directly and must pass through
|
|
143
|
+
* untouched. Custom `@theme` tokens reach the parser as `var(--color-x)`
|
|
144
|
+
* (only the default palette is `theme(inline)`-d), so they flow through the
|
|
145
|
+
* unparsed-string path where the typed {@link cssColorToString} never runs —
|
|
146
|
+
* this is the one place that lowers their wide-gamut values to sRGB.
|
|
147
|
+
*/
|
|
148
|
+
const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(']
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Lower a wide-gamut / modern CSS color STRING (`oklch(…)`, `lab(…)`,
|
|
152
|
+
* `color(display-p3 …)`, …) to an sRGB hex/rgba string RN can paint. Returns
|
|
153
|
+
* `null` for anything RN already understands (hex, rgb, hsl, named) so the
|
|
154
|
+
* caller keeps the original text — only the unrepresentable forms convert.
|
|
155
|
+
* Mirrors {@link cssColorToString}'s culori lowering for the string path.
|
|
156
|
+
* @param text Resolved CSS color text (post theme-var substitution).
|
|
157
|
+
* @returns sRGB color string, or `null` when no conversion is needed/possible.
|
|
158
|
+
*/
|
|
159
|
+
export function normalizeColorString(text: string): string | null {
|
|
160
|
+
const lower = text.trim().toLowerCase()
|
|
161
|
+
if (!RN_UNREADABLE_COLOR_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null
|
|
162
|
+
const parsed = culoriRgb(text)
|
|
163
|
+
if (!parsed || ![parsed.r, parsed.g, parsed.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
|
|
164
|
+
const alpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
|
|
165
|
+
return rgbIntsToString(clampByte(parsed.r * 255), clampByte(parsed.g * 255), clampByte(parsed.b * 255), alpha)
|
|
166
|
+
}
|
|
167
|
+
|
|
137
168
|
/**
|
|
138
169
|
* Convert a lightningcss `CssColor` to an RN-safe color string. RGB
|
|
139
170
|
* passes through unchanged. LAB / LCH / OKLAB / OKLCH / `color(xyz-…)`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable sonarjs/cognitive-complexity -- the main Declaration → RN-entries dispatcher is intentionally a flat switch so each branch keeps its narrowed value type */
|
|
2
2
|
import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
|
|
3
3
|
import { kebabToCamel } from './case-convert'
|
|
4
|
-
import { cssColorToString } from './color'
|
|
4
|
+
import { cssColorToString, normalizeColorString } from './color'
|
|
5
5
|
import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue, sizeLikeToValue } from './length'
|
|
6
6
|
import {
|
|
7
7
|
expandBorderColor,
|
|
@@ -38,6 +38,50 @@ const UNSUPPORTED_LOGICAL_PROPS = new Set([
|
|
|
38
38
|
'border-block-end-style',
|
|
39
39
|
])
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Web-only CSS properties Tailwind v4 emits that have NO React Native style
|
|
43
|
+
* equivalent. Without this denylist they reach the generic `kebabToCamel`
|
|
44
|
+
* fallback and emit dead keys (`objectPosition`, `textWrap`, `willChange`,
|
|
45
|
+
* `float`, `columns`, `-webkit-line-clamp` → `WebkitLineClamp`, …) that bloat
|
|
46
|
+
* every StyleSheet and read as "supported" when they do nothing. Dropping the
|
|
47
|
+
* property name (kebab-case, pre-camel) is safe: it only excludes known
|
|
48
|
+
* web-only props — anything RN supports is handled by a typed branch above.
|
|
49
|
+
* (line-clamp's real RN behaviour comes from `numberOfLines` in text-truncate.)
|
|
50
|
+
*/
|
|
51
|
+
const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
|
|
52
|
+
'object-position',
|
|
53
|
+
'text-wrap',
|
|
54
|
+
'will-change',
|
|
55
|
+
'columns',
|
|
56
|
+
'float',
|
|
57
|
+
'clear',
|
|
58
|
+
'table-layout',
|
|
59
|
+
'caption-side',
|
|
60
|
+
'transform-style',
|
|
61
|
+
'background-blend-mode',
|
|
62
|
+
'scroll-behavior',
|
|
63
|
+
'overscroll-behavior',
|
|
64
|
+
'overscroll-behavior-x',
|
|
65
|
+
'overscroll-behavior-y',
|
|
66
|
+
'scroll-snap-type',
|
|
67
|
+
'scroll-snap-align',
|
|
68
|
+
'scroll-snap-stop',
|
|
69
|
+
'break-after',
|
|
70
|
+
'break-before',
|
|
71
|
+
'break-inside',
|
|
72
|
+
'content',
|
|
73
|
+
'field-sizing',
|
|
74
|
+
'forced-color-adjust',
|
|
75
|
+
'text-shadow',
|
|
76
|
+
'touch-action',
|
|
77
|
+
'backdrop-filter',
|
|
78
|
+
'-webkit-backdrop-filter',
|
|
79
|
+
'-webkit-line-clamp',
|
|
80
|
+
'-webkit-box-orient',
|
|
81
|
+
'-webkit-font-smoothing',
|
|
82
|
+
'-moz-osx-font-smoothing',
|
|
83
|
+
])
|
|
84
|
+
|
|
41
85
|
/** CSS single-sided logical-inline property → RN writing-direction Yoga key. */
|
|
42
86
|
const LOGICAL_INLINE_TO_RN: Record<string, string> = {
|
|
43
87
|
'margin-inline-start': 'marginStart',
|
|
@@ -46,6 +90,22 @@ const LOGICAL_INLINE_TO_RN: Record<string, string> = {
|
|
|
46
90
|
'padding-inline-end': 'paddingEnd',
|
|
47
91
|
}
|
|
48
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Logical border-COLOR property → physical RN side key(s). Custom `@theme`
|
|
95
|
+
* tokens reach the unparsed path as `border-inline-color: var(--color-x)`,
|
|
96
|
+
* which a plain `kebabToCamel` would turn into `borderInlineColor` — a key RN
|
|
97
|
+
* silently drops, so the border color never paints. Lower to the physical
|
|
98
|
+
* keys RN actually honors, matching the typed `dispatchBorderDeclaration`.
|
|
99
|
+
*/
|
|
100
|
+
const LOGICAL_BORDER_COLOR_SIDES: Record<string, readonly string[]> = {
|
|
101
|
+
'border-inline-color': ['borderLeftColor', 'borderRightColor'],
|
|
102
|
+
'border-block-color': ['borderTopColor', 'borderBottomColor'],
|
|
103
|
+
'border-inline-start-color': ['borderLeftColor'],
|
|
104
|
+
'border-inline-end-color': ['borderRightColor'],
|
|
105
|
+
'border-block-start-color': ['borderTopColor'],
|
|
106
|
+
'border-block-end-color': ['borderBottomColor'],
|
|
107
|
+
}
|
|
108
|
+
|
|
49
109
|
/**
|
|
50
110
|
* Pick the closest predefined CSS easing keyword for a `cubic-bezier`
|
|
51
111
|
* control-point set. Mirrors {@link snapCubicBezierToKeyword} in
|
|
@@ -88,6 +148,23 @@ function coerceCubicBezierString(value: string): string {
|
|
|
88
148
|
return snapBezier(Number(x1), Number(y1), Number(x2), Number(y2))
|
|
89
149
|
}
|
|
90
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Whether `text` has a whitespace char OUTSIDE any parenthesised group —
|
|
153
|
+
* the signature of a multi-token CSS value (`2px solid #000`) rather than a
|
|
154
|
+
* single color (`#000`, `rgb(1 2 3)`, `red`).
|
|
155
|
+
* @param text Resolved value text.
|
|
156
|
+
* @returns True when a top-level space is present.
|
|
157
|
+
*/
|
|
158
|
+
function hasTopLevelSpace(text: string): boolean {
|
|
159
|
+
let depth = 0
|
|
160
|
+
for (const ch of text.trim()) {
|
|
161
|
+
if (ch === '(') depth += 1
|
|
162
|
+
else if (ch === ')') depth = Math.max(0, depth - 1)
|
|
163
|
+
else if (depth === 0 && (ch === ' ' || ch === '\t' || ch === '\n')) return true
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
91
168
|
/**
|
|
92
169
|
* Fast-path check for the handful of color property names Tailwind emits.
|
|
93
170
|
* @param property Kebab-case CSS property name.
|
|
@@ -97,6 +174,11 @@ function isColorProperty(property: string): boolean {
|
|
|
97
174
|
return (
|
|
98
175
|
property === 'color' ||
|
|
99
176
|
property === 'background-color' ||
|
|
177
|
+
// SVG paint props (`fill-<token>` / `stroke-<token>` via react-native-svg) —
|
|
178
|
+
// they don't end in `-color`, so without this they'd skip normalization and
|
|
179
|
+
// leak a raw `oklch(…)` string for custom `@theme` tokens.
|
|
180
|
+
property === 'fill' ||
|
|
181
|
+
property === 'stroke' ||
|
|
100
182
|
(property.startsWith('border-') && property.endsWith('-color')) ||
|
|
101
183
|
property.endsWith('-color')
|
|
102
184
|
)
|
|
@@ -118,6 +200,7 @@ function unparsedToEntries(
|
|
|
118
200
|
themeVars: ReadonlyMap<string, string> | undefined,
|
|
119
201
|
): readonly RNEntry[] {
|
|
120
202
|
if (property.length === 0) return []
|
|
203
|
+
if (RN_UNSUPPORTED_PROPERTIES.has(property)) return []
|
|
121
204
|
// Safe-area detection runs BEFORE token serialization because
|
|
122
205
|
// `env()` serializes to an empty string, which would strip the side
|
|
123
206
|
// info we need. If the tokens encode a recognised `env(safe-area-inset-*)`
|
|
@@ -130,12 +213,15 @@ function unparsedToEntries(
|
|
|
130
213
|
if (themeVars && themeVars.size > 0) text = substituteThemeVars(text, themeVars)
|
|
131
214
|
const coerced = coerceUnparsedValue(text)
|
|
132
215
|
if (coerced === null) return []
|
|
133
|
-
// Skip values
|
|
134
|
-
// came from a `@property --tw-*`
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// RN'
|
|
138
|
-
|
|
216
|
+
// Skip values still carrying an unresolved `var(--tw-*)` ANYWHERE in the
|
|
217
|
+
// string — they came from a `@property --tw-*` composable with no real
|
|
218
|
+
// fallback (e.g. `filter: blur(8px) var(--tw-brightness) …`,
|
|
219
|
+
// `transform: rotateX(45deg) var(--tw-rotate-y) …`, `touch-action`,
|
|
220
|
+
// `scroll-snap-type`). RN can't evaluate the cascade, so a leaked `var()`
|
|
221
|
+
// makes the whole declaration an invalid string RN rejects — drop it and
|
|
222
|
+
// rely on RN's default rather than emit garbage. `var(--color-*)` refs are
|
|
223
|
+
// already substituted above, so anything left is a genuine composable miss.
|
|
224
|
+
if (typeof coerced === 'string' && coerced.includes('var(')) return []
|
|
139
225
|
// RN `fontFamily` is a single typeface, not a CSS fallback list — take
|
|
140
226
|
// the first family so `--font-x: "Name", sans-serif` works out of the box.
|
|
141
227
|
if (property === 'font-family' && typeof coerced === 'string') {
|
|
@@ -152,9 +238,21 @@ function unparsedToEntries(
|
|
|
152
238
|
return [[kebabToCamel(property), coerceCubicBezierString(coerced)]]
|
|
153
239
|
}
|
|
154
240
|
if (isColorProperty(property) && typeof coerced === 'string') {
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
241
|
+
// A color is a single token. Tailwind compiles an arbitrary shorthand like
|
|
242
|
+
// `border-[2px_solid_#000]` to `border-color: 2px solid #000` (invalid for
|
|
243
|
+
// a color property → unparsed), which would otherwise emit
|
|
244
|
+
// `borderColor: "2px solid #000000"` — a string RN rejects. A top-level
|
|
245
|
+
// space (outside parens — `rgb(1 2 3)` keeps its inner spaces) means it's a
|
|
246
|
+
// multi-token shorthand, not a color: drop it.
|
|
247
|
+
if (hasTopLevelSpace(coerced)) return []
|
|
248
|
+
// Lower modern color spaces (`oklch(…)`, `lab(…)`, `color(p3 …)`) that
|
|
249
|
+
// RN can't paint to sRGB; hex/rgb/hsl/named pass through unchanged.
|
|
250
|
+
const color = normalizeColorString(coerced) ?? coerced
|
|
251
|
+
// Logical border-color utilities must lower to physical RN side keys —
|
|
252
|
+
// RN ignores `borderInlineColor` / `borderInlineStartColor`.
|
|
253
|
+
const sides = LOGICAL_BORDER_COLOR_SIDES[property]
|
|
254
|
+
if (sides) return sides.map((key): RNEntry => [key, color])
|
|
255
|
+
return [[kebabToCamel(property), color]]
|
|
158
256
|
}
|
|
159
257
|
return [[kebabToCamel(property), coerced]]
|
|
160
258
|
}
|
|
@@ -379,6 +477,17 @@ function dispatchLogicalInline(decl: LcDeclaration): readonly RNEntry[] | null {
|
|
|
379
477
|
const v = lengthPercentageOrAutoToValue(decl.value)
|
|
380
478
|
return v === null ? [] : [['end', v]]
|
|
381
479
|
}
|
|
480
|
+
// Logical border-radius corners (`rounded-s/e/ss/se/ee/es-*`). RN has
|
|
481
|
+
// matching keys — `kebabToCamel('border-start-start-radius')` is exactly
|
|
482
|
+
// `borderStartStartRadius`. Value is a `[x, y]` tuple like physical corners.
|
|
483
|
+
case 'border-start-start-radius':
|
|
484
|
+
case 'border-start-end-radius':
|
|
485
|
+
case 'border-end-start-radius':
|
|
486
|
+
case 'border-end-end-radius': {
|
|
487
|
+
const [xAxis] = decl.value
|
|
488
|
+
const v = dimensionPercentageToNumber(xAxis)
|
|
489
|
+
return v === null ? [] : [[kebabToCamel(decl.property), v]]
|
|
490
|
+
}
|
|
382
491
|
default: {
|
|
383
492
|
return null
|
|
384
493
|
}
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
* {@link GradientAtomInfo} record the transformer can read per atom.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { formatHex as culoriFormatHex } from 'culori'
|
|
25
26
|
import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
|
|
26
|
-
import { cssColorToString } from './color'
|
|
27
|
+
import { cssColorToString, normalizeColorString } from './color'
|
|
28
|
+
import { serializeTokens, substituteThemeVars } from './tokens'
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* The four roles an atom can play in a Tailwind v4 gradient. `from`,
|
|
@@ -59,32 +61,62 @@ export type GradientDirection =
|
|
|
59
61
|
* return the atom's role + data. Returns `null` for rules that don't
|
|
60
62
|
* belong to a gradient utility.
|
|
61
63
|
* @param declarations Declarations from one lightningcss style rule.
|
|
64
|
+
* @param themeVars
|
|
62
65
|
* @returns Gradient info, or null.
|
|
63
66
|
*/
|
|
64
|
-
function detectGradientAtom(
|
|
67
|
+
function detectGradientAtom(
|
|
68
|
+
declarations: readonly LcDeclaration[],
|
|
69
|
+
themeVars?: ReadonlyMap<string, string>,
|
|
70
|
+
): GradientAtomInfo | null {
|
|
65
71
|
for (const decl of declarations) {
|
|
66
72
|
if (decl.property !== 'custom') continue
|
|
67
73
|
const custom = decl.value as { name: { name: string } | string; value?: readonly TokenOrValue[] }
|
|
68
74
|
const name = typeof custom.name === 'string' ? custom.name : custom.name.name
|
|
69
75
|
if (!name.startsWith('--tw-gradient-')) continue
|
|
70
|
-
if (name === '--tw-gradient-from') return fromColor('from', custom.value)
|
|
71
|
-
if (name === '--tw-gradient-via') return fromColor('via', custom.value)
|
|
72
|
-
if (name === '--tw-gradient-to') return fromColor('to', custom.value)
|
|
76
|
+
if (name === '--tw-gradient-from') return fromColor('from', custom.value, themeVars)
|
|
77
|
+
if (name === '--tw-gradient-via') return fromColor('via', custom.value, themeVars)
|
|
78
|
+
if (name === '--tw-gradient-to') return fromColor('to', custom.value, themeVars)
|
|
73
79
|
if (name === '--tw-gradient-position') return fromDirection(custom.value)
|
|
74
80
|
}
|
|
75
81
|
return null
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
85
|
+
* Lower a resolved gradient-stop color string to one RN's `LinearGradient`
|
|
86
|
+
* `colors=` array accepts. Hex/rgb/hsl pass through; modern spaces
|
|
87
|
+
* (`oklch(…)`, `lab(…)`, `color(p3 …)`) and named colors go through culori.
|
|
88
|
+
* @param text Resolved color text (post theme-var substitution).
|
|
89
|
+
* @returns sRGB color string, or null when unparseable.
|
|
90
|
+
*/
|
|
91
|
+
function resolveGradientColor(text: string): string | null {
|
|
92
|
+
if (text.startsWith('#') || text.startsWith('rgb') || text.startsWith('hsl')) return text
|
|
93
|
+
const normalized = normalizeColorString(text)
|
|
94
|
+
if (normalized) return normalized
|
|
95
|
+
try {
|
|
96
|
+
const hex = culoriFormatHex(text)
|
|
97
|
+
return typeof hex === 'string' ? hex : null
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract a stop color from a `--tw-gradient-*` value list. The default
|
|
105
|
+
* Tailwind palette is inlined to a typed `color` token; CUSTOM `@theme`
|
|
106
|
+
* tokens arrive as an unresolved `var(--color-x)` token list, so when no
|
|
107
|
+
* typed color is present we serialize, substitute `themeVars`, and lower
|
|
108
|
+
* the result to sRGB — without this, `from-<token>` / `via-<token>` /
|
|
109
|
+
* `to-<token>` dropped the stop entirely.
|
|
83
110
|
* @param role Target role (`from` / `via` / `to`).
|
|
84
111
|
* @param tokens Value tokens from the `--tw-gradient-*` declaration.
|
|
112
|
+
* @param themeVars Per-scheme var table for resolving `var(--color-x)`.
|
|
85
113
|
* @returns Gradient info, or null.
|
|
86
114
|
*/
|
|
87
|
-
function fromColor(
|
|
115
|
+
function fromColor(
|
|
116
|
+
role: 'from' | 'via' | 'to',
|
|
117
|
+
tokens: readonly TokenOrValue[] | undefined,
|
|
118
|
+
themeVars?: ReadonlyMap<string, string>,
|
|
119
|
+
): GradientAtomInfo | null {
|
|
88
120
|
if (!tokens) return null
|
|
89
121
|
for (const token of tokens) {
|
|
90
122
|
if (token.type !== 'color') continue
|
|
@@ -92,7 +124,12 @@ function fromColor(role: 'from' | 'via' | 'to', tokens: readonly TokenOrValue[]
|
|
|
92
124
|
if (!color) return null
|
|
93
125
|
return { role, color } as GradientAtomInfo
|
|
94
126
|
}
|
|
95
|
-
|
|
127
|
+
// Custom @theme token: value is `var(--color-x)` — serialize + resolve.
|
|
128
|
+
let text = serializeTokens(tokens).trim()
|
|
129
|
+
if (themeVars && themeVars.size > 0) text = substituteThemeVars(text, themeVars)
|
|
130
|
+
if (text.length === 0 || text.startsWith('var(')) return null
|
|
131
|
+
const color = resolveGradientColor(text)
|
|
132
|
+
return color ? ({ role, color } as GradientAtomInfo) : null
|
|
96
133
|
}
|
|
97
134
|
|
|
98
135
|
/**
|
|
@@ -45,8 +45,17 @@ export function keyframesName(raw: KeyframesName): string | null {
|
|
|
45
45
|
*/
|
|
46
46
|
export function keyframeSelectorOffset(selectors: readonly KeyframeSelector[]): string | null {
|
|
47
47
|
const [head] = selectors
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
return head ? offsetForSelector(head) : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render ONE keyframe selector to its CSS offset text, or `null` when it's a
|
|
53
|
+
* timeline-range selector RN can't run.
|
|
54
|
+
* @param selector A single keyframe selector.
|
|
55
|
+
* @returns Offset text (`'from'` / `'to'` / `'50%'`), or `null`.
|
|
56
|
+
*/
|
|
57
|
+
function offsetForSelector(selector: KeyframeSelector): string | null {
|
|
58
|
+
switch (selector.type) {
|
|
50
59
|
case 'from': {
|
|
51
60
|
return 'from'
|
|
52
61
|
}
|
|
@@ -54,7 +63,7 @@ export function keyframeSelectorOffset(selectors: readonly KeyframeSelector[]):
|
|
|
54
63
|
return 'to'
|
|
55
64
|
}
|
|
56
65
|
case 'percentage': {
|
|
57
|
-
return `${
|
|
66
|
+
return `${selector.value * 100}%`
|
|
58
67
|
}
|
|
59
68
|
default: {
|
|
60
69
|
return null
|
|
@@ -62,6 +71,25 @@ export function keyframeSelectorOffset(selectors: readonly KeyframeSelector[]):
|
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Render EVERY representable offset in a frame's selector list. Tailwind
|
|
76
|
+
* collapses shared steps into one frame with multiple selectors — `animate-ping`
|
|
77
|
+
* emits `75%, 100% { … }`, `animate-bounce` emits `0%, 100% { … }`. Reading only
|
|
78
|
+
* the first selector (the old singular helper) dropped the terminal `100%`, so
|
|
79
|
+
* the looping animation never defined its end/return-to-start state. Returns all
|
|
80
|
+
* offsets the frame's style applies to; timeline-range selectors are skipped.
|
|
81
|
+
* @param selectors Step selectors for one frame.
|
|
82
|
+
* @returns Every representable offset (may be empty).
|
|
83
|
+
*/
|
|
84
|
+
export function keyframeSelectorOffsets(selectors: readonly KeyframeSelector[]): readonly string[] {
|
|
85
|
+
const offsets: string[] = []
|
|
86
|
+
for (const selector of selectors) {
|
|
87
|
+
const offset = offsetForSelector(selector)
|
|
88
|
+
if (offset !== null) offsets.push(offset)
|
|
89
|
+
}
|
|
90
|
+
return offsets
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
/**
|
|
66
94
|
* Extract the referenced `@keyframes` name from a declaration whose
|
|
67
95
|
* property is `animation-name` or a shorthand `animation` that names one.
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import type { Declaration as LcDeclaration } from 'lightningcss'
|
|
2
2
|
import type { RNEntry } from './types'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Lower a CSS `overflow` keyword to one RN's `overflow` prop accepts
|
|
6
|
+
* (`'visible' | 'hidden' | 'scroll'`). `auto` → `scroll` (auto means
|
|
7
|
+
* scroll-when-needed), `clip` → `hidden` (closest no-scroll clip). Anything
|
|
8
|
+
* else (an unexpected keyword) drops so RN never sees an invalid value.
|
|
9
|
+
* @param css CSS overflow keyword.
|
|
10
|
+
* @returns RN overflow keyword, or `null` to drop.
|
|
11
|
+
*/
|
|
12
|
+
function mapOverflow(css: string): string | null {
|
|
13
|
+
if (css === 'visible' || css === 'hidden' || css === 'scroll') return css
|
|
14
|
+
if (css === 'auto') return 'scroll'
|
|
15
|
+
if (css === 'clip') return 'hidden'
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the RN `overflow` entry for a raw axis value, mapping it to an
|
|
21
|
+
* RN-valid keyword and dropping unrepresentable shapes. Shared by the
|
|
22
|
+
* `overflow` shorthand and the `overflow-x` / `overflow-y` longhands.
|
|
23
|
+
* @param value Raw per-axis overflow value (string keyword or other).
|
|
24
|
+
* @returns Single `[overflow, …]` entry, or empty when unmappable.
|
|
25
|
+
*/
|
|
26
|
+
function overflowEntry(value: unknown): readonly RNEntry[] {
|
|
27
|
+
const mapped = typeof value === 'string' ? mapOverflow(value) : null
|
|
28
|
+
return mapped === null ? [] : [['overflow', mapped]]
|
|
29
|
+
}
|
|
30
|
+
|
|
4
31
|
/**
|
|
5
32
|
* Lower CSS alignment keywords to the strings RN accepts. CSS uses
|
|
6
33
|
* `start`/`end` while RN sticks with the legacy `flex-start`/`flex-end`.
|
|
@@ -87,14 +114,10 @@ export function dispatchLayoutDeclaration(decl: LcDeclaration): readonly RNEntry
|
|
|
87
114
|
}
|
|
88
115
|
case 'overflow': {
|
|
89
116
|
// Lightningcss splits CSS `overflow` into `{x, y}` axes; RN only
|
|
90
|
-
// supports a single `overflow` keyword
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
// utilities both emit shorthand here so axis splitting is rare.
|
|
95
|
-
const value = decl.value as { x?: unknown; y?: unknown }
|
|
96
|
-
if (typeof value.x !== 'string') return []
|
|
97
|
-
return [['overflow', value.x]]
|
|
117
|
+
// supports a single `overflow` keyword. Take the `x` axis when the
|
|
118
|
+
// user wrote shorthand; per-axis Tailwind utilities both emit
|
|
119
|
+
// shorthand here so axis splitting is rare.
|
|
120
|
+
return overflowEntry((decl.value as { x?: unknown }).x)
|
|
98
121
|
}
|
|
99
122
|
case 'overflow-x':
|
|
100
123
|
case 'overflow-y': {
|
|
@@ -102,7 +125,7 @@ export function dispatchLayoutDeclaration(decl: LcDeclaration): readonly RNEntry
|
|
|
102
125
|
// not the `overflow` shorthand. RN has only a single `overflow`,
|
|
103
126
|
// so collapse both axes onto it (last one declared wins via the
|
|
104
127
|
// normal entry-merge order).
|
|
105
|
-
return
|
|
128
|
+
return overflowEntry(decl.value)
|
|
106
129
|
}
|
|
107
130
|
default: {
|
|
108
131
|
return null
|
|
@@ -69,11 +69,28 @@ export function lengthToPx(length: LengthValue): number {
|
|
|
69
69
|
* @returns Number, percent string, or `null` when unrepresentable.
|
|
70
70
|
*/
|
|
71
71
|
export function dimensionPercentageToNumber(value: DimensionPercentage): LengthResult {
|
|
72
|
-
if (value.type === 'dimension') return lengthToPx(value.value)
|
|
72
|
+
if (value.type === 'dimension') return viewportToPercent(value.value) ?? lengthToPx(value.value)
|
|
73
73
|
if (value.type === 'percentage') return `${roundFloat(value.value * 100)}%`
|
|
74
74
|
return null
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Viewport-relative units — can't resolve to px statically; approximate as `%`. */
|
|
78
|
+
const VIEWPORT_UNITS: ReadonlySet<string> = new Set(['vw', 'vh', 'vmin', 'vmax', 'dvw', 'dvh', 'svw', 'svh', 'lvw', 'lvh'])
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Map a viewport-unit length to a percentage string — `100vw`/`100vh`
|
|
82
|
+
* (e.g. `w-screen`/`h-screen`) become `'100%'`. Viewport units can't be
|
|
83
|
+
* statically resolved to px (they need live `Dimensions`), and `'N%'` is
|
|
84
|
+
* the closest RN-renderable approximation, far better than treating `100vw`
|
|
85
|
+
* as a literal `100px` box. Returns null for non-viewport units.
|
|
86
|
+
* @param length Typed length value.
|
|
87
|
+
* @returns `'N%'` string, or null when not a viewport unit.
|
|
88
|
+
*/
|
|
89
|
+
function viewportToPercent(length: LengthValue): string | null {
|
|
90
|
+
if (!VIEWPORT_UNITS.has(length.unit)) return null
|
|
91
|
+
return `${roundFloat(length.value)}%`
|
|
92
|
+
}
|
|
93
|
+
|
|
77
94
|
/**
|
|
78
95
|
* Convert `LengthPercentageOrAuto` (per-side value type for padding /
|
|
79
96
|
* margin / inset) to an RN scalar. `auto` maps to the string `'auto'`,
|
|
@@ -53,10 +53,16 @@ function detectSafeAreaMarker(tokens: readonly TokenOrValue[], themeVars?: Theme
|
|
|
53
53
|
const nonWs = stripWhitespace(tokens)
|
|
54
54
|
if (nonWs.length === 0) return null
|
|
55
55
|
|
|
56
|
-
// Shape 1: pure env(safe-area-inset-*)
|
|
56
|
+
// Shape 1: pure env(safe-area-inset-*), optionally with a CSS fallback —
|
|
57
|
+
// `env(safe-area-inset-top, 12px)`. The fallback is the author's intended
|
|
58
|
+
// floor when the inset is unavailable, so capture it as `or` (max(inset, N))
|
|
59
|
+
// instead of silently dropping it and collapsing to 0.
|
|
57
60
|
if (nonWs.length === 1) {
|
|
58
61
|
const side = envSide(nonWs[0]!)
|
|
59
|
-
if (side !== null)
|
|
62
|
+
if (side !== null) {
|
|
63
|
+
const or = envFallbackPx(nonWs[0]!, themeVars)
|
|
64
|
+
return or === null ? { __safe: side } : { __safe: side, or }
|
|
65
|
+
}
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
// Shape 2 / 3: max(env(...), n) or calc(env(...) + n)
|
|
@@ -194,6 +200,21 @@ function envSide(token: TokenOrValue): SafeAreaMarker['__safe'] | null {
|
|
|
194
200
|
return SIDE_TAG[name.value] ?? null
|
|
195
201
|
}
|
|
196
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Read an `env(side, <fallback>)` token's CSS fallback as a pixel floor.
|
|
205
|
+
* Returns null when there's no fallback or it isn't a plain length — the
|
|
206
|
+
* caller then emits the bare marker.
|
|
207
|
+
* @param token One TokenOrValue (expected to be an `env` token).
|
|
208
|
+
* @param themeVars Optional theme-vars table for resolving `var()` in the fallback.
|
|
209
|
+
* @returns Fallback length in px, or null.
|
|
210
|
+
*/
|
|
211
|
+
function envFallbackPx(token: TokenOrValue, themeVars: ThemeVars | undefined): number | null {
|
|
212
|
+
if (token.type !== 'env') return null
|
|
213
|
+
const { fallback } = token.value
|
|
214
|
+
if (!fallback || fallback.length === 0) return null
|
|
215
|
+
return coerceLengthPx(stripWhitespace(fallback), themeVars)
|
|
216
|
+
}
|
|
217
|
+
|
|
197
218
|
/**
|
|
198
219
|
* Drop whitespace / comment tokens from a list so downstream branches
|
|
199
220
|
* can pattern-match by index without counting spaces.
|
|
@@ -40,11 +40,18 @@ interface WalkStep {
|
|
|
40
40
|
* @param start Start index of the block body (0 for top-level).
|
|
41
41
|
* @param scheme Active scheme name for declarations inside this scope.
|
|
42
42
|
* @param table Destination table, mutated in place.
|
|
43
|
+
* @param schemeClasses Selector-class → scheme-name map (from `@custom-variant` selectors).
|
|
43
44
|
*/
|
|
44
|
-
function walkBlocks(
|
|
45
|
+
function walkBlocks(
|
|
46
|
+
source: string,
|
|
47
|
+
start: number,
|
|
48
|
+
scheme: string,
|
|
49
|
+
table: ThemeSchemeTable,
|
|
50
|
+
schemeClasses: ReadonlyMap<string, string>,
|
|
51
|
+
): void {
|
|
45
52
|
let index = start
|
|
46
53
|
while (index < source.length) {
|
|
47
|
-
const step = nextWalkStep(source, index, scheme, table)
|
|
54
|
+
const step = nextWalkStep(source, index, scheme, table, schemeClasses)
|
|
48
55
|
if (step.next === -1) return
|
|
49
56
|
index = step.next
|
|
50
57
|
}
|
|
@@ -56,9 +63,16 @@ function walkBlocks(source: string, start: number, scheme: string, table: ThemeS
|
|
|
56
63
|
* @param index Current scan index.
|
|
57
64
|
* @param scheme Active scheme name.
|
|
58
65
|
* @param table Destination table.
|
|
66
|
+
* @param schemeClasses Selector-class → scheme-name map (from `@custom-variant` selectors).
|
|
59
67
|
* @returns Step descriptor with the next scan index (or -1 for EOF).
|
|
60
68
|
*/
|
|
61
|
-
function nextWalkStep(
|
|
69
|
+
function nextWalkStep(
|
|
70
|
+
source: string,
|
|
71
|
+
index: number,
|
|
72
|
+
scheme: string,
|
|
73
|
+
table: ThemeSchemeTable,
|
|
74
|
+
schemeClasses: ReadonlyMap<string, string>,
|
|
75
|
+
): WalkStep {
|
|
62
76
|
// Find the next TOP-LEVEL `--` (outside any `( ... )`). That's the only
|
|
63
77
|
// place a custom-property declaration can start. `--foo` that appears
|
|
64
78
|
// inside `var(--foo)` or `calc(... --value(integer) ...)` is part of a
|
|
@@ -73,7 +87,7 @@ function nextWalkStep(source: string, index: number, scheme: string, table: Them
|
|
|
73
87
|
return { next: consumeDeclaration(source, atIndex, scheme, table) }
|
|
74
88
|
}
|
|
75
89
|
if (openIndex !== -1 && openIndex < blockClose) {
|
|
76
|
-
return { next: enterBlock(source, index, openIndex, scheme, table) }
|
|
90
|
+
return { next: enterBlock(source, index, openIndex, scheme, table, schemeClasses) }
|
|
77
91
|
}
|
|
78
92
|
return { next: -1 }
|
|
79
93
|
}
|
|
@@ -126,9 +140,17 @@ function isDeclarationNext(atIndex: number, openIndex: number, blockClose: numbe
|
|
|
126
140
|
* @param openIndex Index of the opening brace.
|
|
127
141
|
* @param scheme Scheme active in the parent scope.
|
|
128
142
|
* @param table Destination table.
|
|
143
|
+
* @param schemeClasses Selector-class → scheme-name map (from `@custom-variant` selectors).
|
|
129
144
|
* @returns Index past the matching closing brace.
|
|
130
145
|
*/
|
|
131
|
-
function enterBlock(
|
|
146
|
+
function enterBlock(
|
|
147
|
+
source: string,
|
|
148
|
+
index: number,
|
|
149
|
+
openIndex: number,
|
|
150
|
+
scheme: string,
|
|
151
|
+
table: ThemeSchemeTable,
|
|
152
|
+
schemeClasses: ReadonlyMap<string, string>,
|
|
153
|
+
): number {
|
|
132
154
|
const header = source.slice(index, openIndex).trim()
|
|
133
155
|
// Skip blocks that define utilities / at-rules that carry declarations
|
|
134
156
|
// meant for a downstream compiler, not custom-property values for the
|
|
@@ -136,11 +158,55 @@ function enterBlock(source: string, index: number, openIndex: number, scheme: st
|
|
|
136
158
|
// `--value(...)` meta-syntax which would otherwise confuse the
|
|
137
159
|
// top-level declaration walker and spill into the extracted theme.
|
|
138
160
|
if (isNonThemeAtRule(header)) return skipMatchingBrace(source, openIndex + 1)
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
// Scheme scope switches on EITHER an `@variant <name> {` block OR a plain
|
|
162
|
+
// selector block targeting a scheme class (`.dark { … }` — Tailwind v4's
|
|
163
|
+
// standard dark-mode shape, often wrapped in `@layer base`). Without the
|
|
164
|
+
// selector case, `.dark`'s overrides poured into `base` and overwrote the
|
|
165
|
+
// light defaults, so every scheme rendered the dark values.
|
|
166
|
+
const childScheme = variantNameOf(header) ?? schemeFromSelector(header, schemeClasses) ?? scheme
|
|
167
|
+
walkBlocks(source, openIndex + 1, childScheme, table, schemeClasses)
|
|
141
168
|
return skipMatchingBrace(source, openIndex + 1)
|
|
142
169
|
}
|
|
143
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Map a plain selector block header to the scheme it overrides, using the
|
|
173
|
+
* class → scheme map derived from `@custom-variant` declarations. Returns the
|
|
174
|
+
* first scheme class found in the selector (`.dark { … }` → `dark`,
|
|
175
|
+
* `.scheme-dark, .scheme-dark * { … }` → `dark`), or null for at-rules and
|
|
176
|
+
* non-scheme selectors (which keep the parent scheme).
|
|
177
|
+
* @param header Block header text (the selector before the `{`).
|
|
178
|
+
* @param schemeClasses Class-name → scheme-name map.
|
|
179
|
+
* @returns Scheme name, or null.
|
|
180
|
+
*/
|
|
181
|
+
function schemeFromSelector(header: string, schemeClasses: ReadonlyMap<string, string>): string | null {
|
|
182
|
+
if (header.startsWith('@') || schemeClasses.size === 0) return null
|
|
183
|
+
for (const match of header.matchAll(CLASS_IN_SELECTOR)) {
|
|
184
|
+
const mapped = schemeClasses.get(match[1]!)
|
|
185
|
+
if (mapped) return mapped
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a `<selector-class> → <scheme-name>` map from every
|
|
192
|
+
* `@custom-variant <name> (<class-selector>);` declaration. Unlike
|
|
193
|
+
* {@link extractSchemeAliases} this KEEPS the `class === scheme` case
|
|
194
|
+
* (`.dark → dark`) — that's exactly the mapping needed to attribute a
|
|
195
|
+
* `.dark { … }` override block to the dark scheme.
|
|
196
|
+
* @param css Pre-stripped CSS source.
|
|
197
|
+
* @returns Class-name → scheme-name map.
|
|
198
|
+
*/
|
|
199
|
+
function buildSchemeClassMap(css: string): Map<string, string> {
|
|
200
|
+
const map = new Map<string, string>()
|
|
201
|
+
for (const match of css.matchAll(CUSTOM_VARIANT_WITH_SELECTOR)) {
|
|
202
|
+
const scheme = match[1]!
|
|
203
|
+
const selector = match[2]!
|
|
204
|
+
if (!isSchemeSelector(selector)) continue
|
|
205
|
+
for (const cls of selector.matchAll(CLASS_IN_SELECTOR)) map.set(cls[1]!, scheme)
|
|
206
|
+
}
|
|
207
|
+
return map
|
|
208
|
+
}
|
|
209
|
+
|
|
144
210
|
/**
|
|
145
211
|
* Whether a block header belongs to an at-rule whose body should be
|
|
146
212
|
* ignored by the theme-var extractor. `@utility` / `@media` / `@keyframes`
|
|
@@ -351,7 +417,8 @@ export const BASE_SCHEME = 'base'
|
|
|
351
417
|
export function extractThemeVars(css: string): ThemeSchemeTable {
|
|
352
418
|
const table: ThemeSchemeTable = new Map()
|
|
353
419
|
const stripped = stripComments(css)
|
|
354
|
-
|
|
420
|
+
const schemeClasses = buildSchemeClassMap(stripped)
|
|
421
|
+
walkBlocks(stripped, 0, BASE_SCHEME, table, schemeClasses)
|
|
355
422
|
return table
|
|
356
423
|
}
|
|
357
424
|
|