rnwind 0.0.2 → 0.0.3
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 +53 -24
- package/lib/cjs/core/parser/color.cjs.map +1 -1
- package/lib/cjs/core/parser/layout-dispatcher.cjs +20 -0
- package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/length.cjs +20 -6
- package/lib/cjs/core/parser/length.cjs.map +1 -1
- package/lib/cjs/core/parser/length.d.ts +6 -3
- package/lib/cjs/core/parser/shorthand.cjs +37 -5
- package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
- package/lib/cjs/core/parser/shorthand.d.ts +11 -5
- package/lib/cjs/core/parser/theme-vars.cjs +53 -0
- package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
- package/lib/cjs/core/parser/theme-vars.d.ts +21 -0
- package/lib/cjs/core/parser/tokens.cjs +183 -1
- package/lib/cjs/core/parser/tokens.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.cjs +140 -27
- package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.d.ts +21 -5
- package/lib/cjs/core/parser/typography-dispatcher.cjs +16 -1
- package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/style-builder/build-style.cjs +73 -26
- package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
- package/lib/cjs/metro/state.cjs +52 -2
- package/lib/cjs/metro/state.cjs.map +1 -1
- package/lib/cjs/metro/state.d.ts +17 -1
- package/lib/cjs/metro/transform-ast.cjs +238 -21
- package/lib/cjs/metro/transform-ast.cjs.map +1 -1
- package/lib/cjs/metro/transform-ast.d.ts +15 -0
- package/lib/cjs/metro/transformer.cjs +29 -2
- package/lib/cjs/metro/transformer.cjs.map +1 -1
- package/lib/cjs/metro/with-config.cjs +1 -1
- package/lib/cjs/metro/with-config.cjs.map +1 -1
- package/lib/cjs/metro/with-config.d.ts +22 -0
- package/lib/esm/core/parser/color.mjs +53 -24
- package/lib/esm/core/parser/color.mjs.map +1 -1
- package/lib/esm/core/parser/layout-dispatcher.mjs +20 -0
- package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/length.d.ts +6 -3
- package/lib/esm/core/parser/length.mjs +20 -6
- package/lib/esm/core/parser/length.mjs.map +1 -1
- package/lib/esm/core/parser/shorthand.d.ts +11 -5
- package/lib/esm/core/parser/shorthand.mjs +37 -5
- package/lib/esm/core/parser/shorthand.mjs.map +1 -1
- package/lib/esm/core/parser/theme-vars.d.ts +21 -0
- package/lib/esm/core/parser/theme-vars.mjs +53 -1
- package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
- package/lib/esm/core/parser/tokens.mjs +183 -1
- package/lib/esm/core/parser/tokens.mjs.map +1 -1
- package/lib/esm/core/parser/tw-parser.d.ts +21 -5
- package/lib/esm/core/parser/tw-parser.mjs +141 -28
- package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs +16 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
- package/lib/esm/core/style-builder/build-style.mjs +73 -26
- package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
- package/lib/esm/metro/state.d.ts +17 -1
- package/lib/esm/metro/state.mjs +51 -3
- package/lib/esm/metro/state.mjs.map +1 -1
- package/lib/esm/metro/transform-ast.d.ts +15 -0
- package/lib/esm/metro/transform-ast.mjs +238 -21
- package/lib/esm/metro/transform-ast.mjs.map +1 -1
- package/lib/esm/metro/transformer.mjs +30 -3
- package/lib/esm/metro/transformer.mjs.map +1 -1
- package/lib/esm/metro/with-config.d.ts +22 -0
- package/lib/esm/metro/with-config.mjs +1 -1
- package/lib/esm/metro/with-config.mjs.map +1 -1
- package/package.json +2 -1
- package/src/core/parser/color.ts +52 -18
- package/src/core/parser/layout-dispatcher.ts +19 -0
- package/src/core/parser/length.ts +20 -6
- package/src/core/parser/shorthand.ts +35 -5
- package/src/core/parser/theme-vars.ts +53 -0
- package/src/core/parser/tokens.ts +171 -1
- package/src/core/parser/tw-parser.ts +147 -28
- package/src/core/parser/typography-dispatcher.ts +15 -1
- package/src/core/style-builder/build-style.ts +84 -26
- package/src/metro/state.ts +49 -1
- package/src/metro/transform-ast.ts +249 -18
- package/src/metro/transformer.ts +28 -3
- package/src/metro/with-config.ts +23 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Token, TokenOrValue } from 'lightningcss'
|
|
2
|
+
import { rgb as culoriRgb } from 'culori'
|
|
2
3
|
import { BARE_NUMBER_REGEX, CALC_MUL_REGEX, CALC_RATIO_REGEX, LENGTH_PX_REGEX, LENGTH_REM_REGEX, REM_TO_PX } from './constants'
|
|
3
4
|
import { cssColorToString } from './color'
|
|
4
5
|
import type { RNStyleValue } from './types'
|
|
@@ -244,6 +245,8 @@ export function coerceUnparsedValue(text: string): RNStyleValue | null {
|
|
|
244
245
|
if (px) return Number(px[1])
|
|
245
246
|
const rem = LENGTH_REM_REGEX.exec(trimmed)
|
|
246
247
|
if (rem) return Number(rem[1]) * REM_TO_PX
|
|
248
|
+
const colorMix = evaluateColorMixWithTransparent(trimmed)
|
|
249
|
+
if (colorMix !== null) return colorMix
|
|
247
250
|
const fallback = extractVariableFallback(trimmed)
|
|
248
251
|
if (fallback !== null) return coerceUnparsedValue(fallback)
|
|
249
252
|
const calcRatio = CALC_RATIO_REGEX.exec(trimmed)
|
|
@@ -262,7 +265,174 @@ export function coerceUnparsedValue(text: string): RNStyleValue | null {
|
|
|
262
265
|
const base = Number(calcMul[1]) * Number(calcMul[2])
|
|
263
266
|
return unit === 'rem' ? base * REM_TO_PX : base
|
|
264
267
|
}
|
|
265
|
-
return trimmed
|
|
268
|
+
return unquoteCssString(trimmed)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Evaluate the specific `color-mix(in <space>, <color> <pct>%, transparent)`
|
|
273
|
+
* shape Tailwind v4 emits for opacity-suffixed themed colors (e.g.
|
|
274
|
+
* `border-text/20`, `bg-on-background/30`). The result is the original
|
|
275
|
+
* color with `alpha = originalAlpha * pct/100` — no actual color-space
|
|
276
|
+
* conversion is needed because mixing a color with `transparent` only
|
|
277
|
+
* changes its alpha, regardless of the named space (oklab / srgb /
|
|
278
|
+
* lab / …) — the chrominance is preserved.
|
|
279
|
+
*
|
|
280
|
+
* Returns null when the expression isn't this shape (handed back to
|
|
281
|
+
* the caller for the next coercion strategy).
|
|
282
|
+
* @param text Trimmed CSS value.
|
|
283
|
+
* @returns RN-compatible `rgba(...)` string, or null when unmatched.
|
|
284
|
+
*/
|
|
285
|
+
function evaluateColorMixWithTransparent(text: string): string | null {
|
|
286
|
+
const lower = text.toLowerCase()
|
|
287
|
+
if (!lower.startsWith('color-mix(')) return null
|
|
288
|
+
// Match the trailing `, transparent)` (allowing optional whitespace).
|
|
289
|
+
const tail = /,\s*transparent\s*\)\s*$/.exec(text)
|
|
290
|
+
if (!tail) return null
|
|
291
|
+
// Skip the `in <space>` clause (everything up to the FIRST comma after
|
|
292
|
+
// the opening paren). Walking by hand instead of regex because the
|
|
293
|
+
// color slot may itself contain `(...)` (e.g. `rgb(...)`).
|
|
294
|
+
const inComma = text.indexOf(',', 'color-mix('.length)
|
|
295
|
+
if (inComma === -1 || inComma > tail.index) return null
|
|
296
|
+
const middle = text.slice(inComma + 1, tail.index).trim()
|
|
297
|
+
// `<color> <pct>%` — the `<num>%` token is at the END of `middle`.
|
|
298
|
+
// Anchored, no backtracking — explicit `% ` then end.
|
|
299
|
+
const pctMatch = COLOR_MIX_PCT_TAIL.exec(middle)
|
|
300
|
+
if (!pctMatch) return null
|
|
301
|
+
const pct = Number(pctMatch[1]) / 100
|
|
302
|
+
if (!Number.isFinite(pct)) return null
|
|
303
|
+
const colorText = middle.slice(0, pctMatch.index).trim()
|
|
304
|
+
if (colorText.length === 0) return null
|
|
305
|
+
return applyAlphaToCssColor(colorText, pct)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** End-anchored `<num>%` matcher used to slice a color-mix percentage off the right of an expression. */
|
|
309
|
+
// eslint-disable-next-line sonarjs/slow-regex -- end-anchored, atomic-style group; bounded backtracking is safe.
|
|
310
|
+
const COLOR_MIX_PCT_TAIL = /(-?\d+(?:\.\d+)?)%$/
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Multiply the alpha channel of a serialized CSS color by `multiplier`
|
|
314
|
+
* (0…1). Recognises `#rgb` / `#rrggbb` / `#rrggbbaa` hex, named colors
|
|
315
|
+
* (only `transparent` matters here), and `rgb(…)` / `rgba(…)` forms —
|
|
316
|
+
* which is what theme tokens resolve to after substitution.
|
|
317
|
+
* @param color CSS color text.
|
|
318
|
+
* @param multiplier Alpha multiplier (0…1).
|
|
319
|
+
* @returns `rgba(r, g, b, a)` string with the adjusted alpha.
|
|
320
|
+
*/
|
|
321
|
+
function applyAlphaToCssColor(color: string, multiplier: number): string | null {
|
|
322
|
+
const trimmed = color.trim()
|
|
323
|
+
if (trimmed === 'transparent') return 'rgba(0, 0, 0, 0)'
|
|
324
|
+
return alphaFromHex(trimmed, multiplier) ?? alphaFromRgbFunction(trimmed, multiplier) ?? alphaFromCulori(trimmed, multiplier)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Apply the alpha multiplier to a hex literal, expanding 3/4/6/8-digit forms.
|
|
329
|
+
* @param text
|
|
330
|
+
* @param multiplier
|
|
331
|
+
*/
|
|
332
|
+
function alphaFromHex(text: string, multiplier: number): string | null {
|
|
333
|
+
const hexMatch = /^#([0-9a-fA-F]{3,8})$/.exec(text)
|
|
334
|
+
if (!hexMatch) return null
|
|
335
|
+
const expanded = expandHex(hexMatch[1]!)
|
|
336
|
+
if (!expanded) return null
|
|
337
|
+
return `rgba(${expanded.r}, ${expanded.g}, ${expanded.b}, ${expanded.alpha * multiplier})`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Apply alpha to an `rgb(…)` / `rgba(…)` literal. Walks the channels by
|
|
342
|
+
* hand instead of a multi-capture regex (the linter flags the regex
|
|
343
|
+
* form as backtracking-prone).
|
|
344
|
+
* @param text
|
|
345
|
+
* @param multiplier
|
|
346
|
+
*/
|
|
347
|
+
function alphaFromRgbFunction(text: string, multiplier: number): string | null {
|
|
348
|
+
if (!text.startsWith('rgb(') && !text.startsWith('rgba(')) return null
|
|
349
|
+
const inner = text.slice(text.indexOf('(') + 1, -1)
|
|
350
|
+
const channels = inner.split(/\s*[\s,]\s*/).filter((part) => part.length > 0)
|
|
351
|
+
if (channels.length !== 3 && channels.length !== 4) return null
|
|
352
|
+
const r = Math.round(Number(channels[0]))
|
|
353
|
+
const g = Math.round(Number(channels[1]))
|
|
354
|
+
const b = Math.round(Number(channels[2]))
|
|
355
|
+
const baseAlpha = channels.length === 4 ? Number(channels[3]) : 1
|
|
356
|
+
if (![r, g, b, baseAlpha].every((value) => Number.isFinite(value))) return null
|
|
357
|
+
return `rgba(${r}, ${g}, ${b}, ${baseAlpha * multiplier})`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Fallback for wide-gamut color forms (`oklch`, `oklab`, `lab`, `lch`,
|
|
362
|
+
* `hsl`, …) — culori parses every CSS color shape and yields RGB. Lets
|
|
363
|
+
* `color-mix(in oklab, oklch(...) 50%, transparent)` resolve when
|
|
364
|
+
* Tailwind emits the source color in a wide-gamut space (every
|
|
365
|
+
* built-in `bg-red-500` / `shadow-red-500` does, in v4).
|
|
366
|
+
* @param text
|
|
367
|
+
* @param multiplier
|
|
368
|
+
*/
|
|
369
|
+
function alphaFromCulori(text: string, multiplier: number): string | null {
|
|
370
|
+
try {
|
|
371
|
+
const parsed = culoriRgb(text) as { r?: number; g?: number; b?: number; alpha?: number } | undefined
|
|
372
|
+
if (!parsed) return null
|
|
373
|
+
if (![parsed.r, parsed.g, parsed.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
|
|
374
|
+
const r = Math.round(Math.max(0, Math.min(1, parsed.r!)) * 255)
|
|
375
|
+
const g = Math.round(Math.max(0, Math.min(1, parsed.g!)) * 255)
|
|
376
|
+
const b = Math.round(Math.max(0, Math.min(1, parsed.b!)) * 255)
|
|
377
|
+
const baseAlpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
|
|
378
|
+
return `rgba(${r}, ${g}, ${b}, ${baseAlpha * multiplier})`
|
|
379
|
+
} catch {
|
|
380
|
+
// culori threw on an unrecognised CSS form — fall through.
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Expand `#rgb` / `#rrggbb` / `#rrggbbaa` hex to its `{r, g, b, alpha}`
|
|
387
|
+
* components. Returns null when the digit count doesn't match a CSS hex
|
|
388
|
+
* shape.
|
|
389
|
+
* @param digits Hex digits without the leading `#`.
|
|
390
|
+
* @returns Decoded color or null.
|
|
391
|
+
*/
|
|
392
|
+
function expandHex(digits: string): { r: number; g: number; b: number; alpha: number } | null {
|
|
393
|
+
const {length} = digits
|
|
394
|
+
if (length === 3 || length === 4) {
|
|
395
|
+
const r = Number.parseInt(digits[0]! + digits[0]!, 16)
|
|
396
|
+
const g = Number.parseInt(digits[1]! + digits[1]!, 16)
|
|
397
|
+
const b = Number.parseInt(digits[2]! + digits[2]!, 16)
|
|
398
|
+
const alpha = length === 4 ? Number.parseInt(digits[3]! + digits[3]!, 16) / 255 : 1
|
|
399
|
+
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b, alpha }
|
|
400
|
+
}
|
|
401
|
+
if (length === 6 || length === 8) {
|
|
402
|
+
const r = Number.parseInt(digits.slice(0, 2), 16)
|
|
403
|
+
const g = Number.parseInt(digits.slice(2, 4), 16)
|
|
404
|
+
const b = Number.parseInt(digits.slice(4, 6), 16)
|
|
405
|
+
const alpha = length === 8 ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1
|
|
406
|
+
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : { r, g, b, alpha }
|
|
407
|
+
}
|
|
408
|
+
return null
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Strip the matched outer quote characters from a CSS string literal.
|
|
413
|
+
* `--font-sans: 'Inter-Medium'` flows through `var(--font-sans)`
|
|
414
|
+
* substitution as the raw value text — quotes included. Without this
|
|
415
|
+
* step `fontFamily` lands on the RN style as `"'Inter-Medium'"` (with
|
|
416
|
+
* literal quote characters), which RN can't match against the registered
|
|
417
|
+
* native font and silently falls back to the system face.
|
|
418
|
+
*
|
|
419
|
+
* Only strips when both ends agree (`'…'` or `"…"`) and there are no
|
|
420
|
+
* other top-level quote chars — keeps multi-segment fallbacks like
|
|
421
|
+
* `'Inter', sans-serif` untouched (those get split downstream).
|
|
422
|
+
* @param text Trimmed CSS value.
|
|
423
|
+
* @returns Same text with outer matching quotes removed, or unchanged.
|
|
424
|
+
*/
|
|
425
|
+
function unquoteCssString(text: string): string {
|
|
426
|
+
if (text.length < 2) return text
|
|
427
|
+
const first = text.codePointAt(0)
|
|
428
|
+
const last = text.codePointAt(text.length - 1)
|
|
429
|
+
if (first === undefined || first !== last) return text
|
|
430
|
+
if (first !== 34 && first !== 39) return text // " or '
|
|
431
|
+
const inner = text.slice(1, -1)
|
|
432
|
+
// Don't unquote when the inner string itself contains an unescaped
|
|
433
|
+
// matching quote — that means we'd be merging two adjacent literals.
|
|
434
|
+
if (inner.includes(text[0]!)) return text
|
|
435
|
+
return inner
|
|
266
436
|
}
|
|
267
437
|
|
|
268
438
|
/**
|
|
@@ -8,7 +8,14 @@ import { detectHapticAtom, type HapticRequest } from './haptics'
|
|
|
8
8
|
import { keyframeSelectorOffset, keyframesName, pickAnimationName } from './keyframes'
|
|
9
9
|
import { serializeInitialValue } from './property'
|
|
10
10
|
import { classNameFromSelector } from './selector'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
BASE_SCHEME,
|
|
13
|
+
compileReadyTheme,
|
|
14
|
+
extractCustomVariantSchemes,
|
|
15
|
+
extractSchemeAliases,
|
|
16
|
+
extractThemeVars,
|
|
17
|
+
type ThemeSchemeTable,
|
|
18
|
+
} from './theme-vars'
|
|
12
19
|
import { serializeTokens } from './tokens'
|
|
13
20
|
import type { RNStyle } from './types'
|
|
14
21
|
import type { Declaration as LcDeclaration, TokenOrValue } from 'lightningcss'
|
|
@@ -34,14 +41,18 @@ const DEFAULT_TRANSFORM_OPTIONS: Partial<TransformOptions<never>> = {
|
|
|
34
41
|
},
|
|
35
42
|
include: Features.Nesting | Features.MediaQueries,
|
|
36
43
|
exclude: Features.LogicalProperties | Features.DirSelector | Features.LightDark,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
// NOTE: deliberately no `targets`. With targets that include
|
|
45
|
+
// color-mix-supporting browsers (Safari 16.4+, Chrome 111+, …),
|
|
46
|
+
// lightningcss EVALUATES `color-mix(in oklab, var(--theme-color)
|
|
47
|
+
// <pct>%, transparent)` at parse time using whichever value of
|
|
48
|
+
// `--theme-color` it sees first in the cascade. Tailwind v4 emits
|
|
49
|
+
// exactly this shape for `<prop>-<themed>/<N>` utilities (e.g.
|
|
50
|
+
// `border-text/20`), so the resulting RGB color is locked to ONE
|
|
51
|
+
// scheme — every variant gets the same value. By dropping targets,
|
|
52
|
+
// lightningcss leaves color-mix as an unparsed function and our
|
|
53
|
+
// per-scheme `unparsedToEntries` substitution path runs instead,
|
|
54
|
+
// producing the right rgba(...) for each scheme. Targets in this
|
|
55
|
+
// pipeline are otherwise unused — we never re-emit CSS from the AST.
|
|
45
56
|
}
|
|
46
57
|
/** Parser configuration — one instance per Metro session, theme CSS fixed. */
|
|
47
58
|
export interface TailwindParserConfig {
|
|
@@ -164,6 +175,13 @@ export class TailwindParser {
|
|
|
164
175
|
private compiler: TailwindCompiler | undefined
|
|
165
176
|
private readonly themeSchemes: ThemeSchemeTable
|
|
166
177
|
private readonly schemeAliases: ReadonlyMap<string, string>
|
|
178
|
+
/**
|
|
179
|
+
* Scheme names declared via `@custom-variant <name> …;`. A scheme
|
|
180
|
+
* listed here but absent from {@link themeSchemes} (no `@variant`
|
|
181
|
+
* override block) draws its values from the base `@theme` — the
|
|
182
|
+
* standard Tailwind v4 "light defaults + dark override" shape.
|
|
183
|
+
*/
|
|
184
|
+
private readonly customVariantSchemes: readonly string[]
|
|
167
185
|
/**
|
|
168
186
|
* Memoise `resolveCandidates` results by candidate-list fingerprint.
|
|
169
187
|
* Fast Refresh hits this on every save: oxide's scan is cheap, but
|
|
@@ -187,20 +205,41 @@ export class TailwindParser {
|
|
|
187
205
|
constructor(private readonly config: TailwindParserConfig) {
|
|
188
206
|
this.themeSchemes = extractThemeVars(config.themeCss)
|
|
189
207
|
this.schemeAliases = extractSchemeAliases(config.themeCss)
|
|
208
|
+
this.customVariantSchemes = extractCustomVariantSchemes(config.themeCss)
|
|
190
209
|
this.scanner = new Scanner({ sources: config.sources ? [...config.sources] : [] })
|
|
191
210
|
}
|
|
192
211
|
|
|
193
212
|
/**
|
|
194
|
-
* Schemes declared by the user —
|
|
195
|
-
*
|
|
196
|
-
* any
|
|
197
|
-
* per-atom resolver fills. Exposed publicly so
|
|
198
|
-
* hand the names to the `.d.ts` generator
|
|
213
|
+
* Schemes declared by the user — the union of every `@custom-variant
|
|
214
|
+
* <name>` declaration and every `@variant <name>` block, or just
|
|
215
|
+
* `['base']` for themes without any. Used to decide how many
|
|
216
|
+
* per-scheme buckets the per-atom resolver fills. Exposed publicly so
|
|
217
|
+
* Metro integration can hand the names to the `.d.ts` generator
|
|
218
|
+
* without a full parse.
|
|
219
|
+
*
|
|
220
|
+
* Both sources matter. `@variant` blocks alone miss the common
|
|
221
|
+
* Tailwind v4 shape where the light palette sits in the base `@theme`
|
|
222
|
+
* and only `@variant dark` overrides it: there `light` exists solely
|
|
223
|
+
* as a `@custom-variant` and would otherwise be dropped, collapsing
|
|
224
|
+
* every themed atom to a single bucket that can't switch.
|
|
225
|
+
* `@custom-variant` order wins (it's where users enumerate their
|
|
226
|
+
* schemes); any `@variant`-only scheme is appended after.
|
|
199
227
|
* @returns Scheme names.
|
|
200
228
|
*/
|
|
201
229
|
public get declaredSchemes(): readonly string[] {
|
|
202
|
-
const
|
|
203
|
-
|
|
230
|
+
const ordered: string[] = []
|
|
231
|
+
const seen = new Set<string>()
|
|
232
|
+
for (const name of this.customVariantSchemes) {
|
|
233
|
+
if (seen.has(name)) continue
|
|
234
|
+
seen.add(name)
|
|
235
|
+
ordered.push(name)
|
|
236
|
+
}
|
|
237
|
+
for (const name of this.themeSchemes.keys()) {
|
|
238
|
+
if (name === BASE_SCHEME || seen.has(name)) continue
|
|
239
|
+
seen.add(name)
|
|
240
|
+
ordered.push(name)
|
|
241
|
+
}
|
|
242
|
+
return ordered.length > 0 ? ordered : [BASE_SCHEME]
|
|
204
243
|
}
|
|
205
244
|
|
|
206
245
|
/**
|
|
@@ -291,6 +330,19 @@ export class TailwindParser {
|
|
|
291
330
|
} catch (error) {
|
|
292
331
|
throw wrapThemeError(error)
|
|
293
332
|
}
|
|
333
|
+
// Tailwind v4 emits opacity-suffixed themed colors as a pre-resolved
|
|
334
|
+
// sRGB fallback PLUS a `@supports`-gated var()-based override:
|
|
335
|
+
// border-color: color-mix(in srgb, #0A0A0A 20%, transparent);
|
|
336
|
+
// @supports (color: color-mix(in lab, red, red)) {
|
|
337
|
+
// border-color: color-mix(in oklab, var(--color-text) 20%, transparent);
|
|
338
|
+
// }
|
|
339
|
+
// Lightningcss takes the OUTER fallback (locked to whichever scheme
|
|
340
|
+
// the compiler resolved first), and our per-scheme substitution
|
|
341
|
+
// never gets a chance. Unwrap the @supports so the var()-based
|
|
342
|
+
// declaration overrides the fallback in the same rule — lightningcss
|
|
343
|
+
// emits the override as `unparsed` and the parser's themeVars-aware
|
|
344
|
+
// path produces correct rgba per scheme.
|
|
345
|
+
css = unwrapColorMixSupports(css)
|
|
294
346
|
// `compiler.build(candidates)` memoizes across calls — it returns CSS for
|
|
295
347
|
// every candidate the compiler has EVER seen in this process. To keep
|
|
296
348
|
// parser output pure per-call we restrict outputs to this call's
|
|
@@ -1068,20 +1120,24 @@ function parseFirstShadow(raw: string): ParsedShadow | null {
|
|
|
1068
1120
|
* @returns Pixel lengths + the remainder text (color expression).
|
|
1069
1121
|
*/
|
|
1070
1122
|
function extractShadowLengths(head: string): { lengths: number[]; remainder: string } {
|
|
1071
|
-
|
|
1123
|
+
// Take ONLY the leading run of length tokens, stopping at the first
|
|
1124
|
+
// non-length token (the color). A previous global digit-regex scanned
|
|
1125
|
+
// the whole string, so a <4-length shadow like `0 1px 1px rgb(0 0 0 /
|
|
1126
|
+
// 0.05)` stole a digit out of the color expression — corrupting the
|
|
1127
|
+
// alpha (opacity) or a digit-leading hex. Whitespace-splitting can't
|
|
1128
|
+
// reach inside the color because we break as soon as a token isn't a
|
|
1129
|
+
// bare/`px`/`rem`/`em`/`%` length.
|
|
1130
|
+
// Unambiguous integer-or-decimal (no `\d*\.?\d+` overlap) so there's no
|
|
1131
|
+
// super-linear backtracking on long digit runs.
|
|
1132
|
+
const isLength = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)?$/
|
|
1133
|
+
const parts = head.split(/\s+/)
|
|
1072
1134
|
const lengths: number[] = []
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
lengths.push(parseLengthToken(next[0]))
|
|
1078
|
-
next = lengthRegex.exec(head)
|
|
1079
|
-
}
|
|
1080
|
-
let remainder = head
|
|
1081
|
-
for (const { text, index } of matches.toReversed()) {
|
|
1082
|
-
remainder = `${remainder.slice(0, index)}${remainder.slice(index + text.length)}`
|
|
1135
|
+
let index = 0
|
|
1136
|
+
while (index < parts.length && lengths.length < 4 && isLength.test(parts[index]!)) {
|
|
1137
|
+
lengths.push(parseLengthToken(parts[index]!))
|
|
1138
|
+
index += 1
|
|
1083
1139
|
}
|
|
1084
|
-
return { lengths, remainder }
|
|
1140
|
+
return { lengths, remainder: parts.slice(index).join(' ') }
|
|
1085
1141
|
}
|
|
1086
1142
|
|
|
1087
1143
|
/**
|
|
@@ -1618,6 +1674,69 @@ function resolveAngleExpression(text: string): string | null {
|
|
|
1618
1674
|
* @param css Tailwind's compiled CSS.
|
|
1619
1675
|
* @returns Map of custom-property name → resolved value.
|
|
1620
1676
|
*/
|
|
1677
|
+
/**
|
|
1678
|
+
* Strip `\@supports (color: color-mix(in lab, red, red)) { … }` wrappers
|
|
1679
|
+
* from Tailwind v4's compiled CSS, hoisting their inner declarations up
|
|
1680
|
+
* to the parent rule.
|
|
1681
|
+
*
|
|
1682
|
+
* Tailwind emits opacity-suffixed themed colors with both a pre-resolved
|
|
1683
|
+
* sRGB fallback AND a var()-based override gated behind the color-mix
|
|
1684
|
+
* `\@supports` clause. The OUTER fallback hard-codes a single scheme's
|
|
1685
|
+
* value of the theme token; the inner override is var()-based and
|
|
1686
|
+
* substitutes correctly per scheme. By unwrapping the gate, the inner
|
|
1687
|
+
* declaration becomes a sibling of the fallback in the same rule body —
|
|
1688
|
+
* lightningcss takes the LATER one (the var()-based unparsed form), and
|
|
1689
|
+
* the parser's themeVars-aware path produces correct rgba per scheme.
|
|
1690
|
+
* Modern RN-targeted browsers all support color-mix anyway, so dropping
|
|
1691
|
+
* the gating is safe.
|
|
1692
|
+
* @param css Tailwind-compiled CSS.
|
|
1693
|
+
* @returns CSS with the color-mix support gates unwrapped.
|
|
1694
|
+
*/
|
|
1695
|
+
function unwrapColorMixSupports(css: string): string {
|
|
1696
|
+
const guard = '@supports (color: color-mix(in lab, red, red))'
|
|
1697
|
+
let out = ''
|
|
1698
|
+
let cursor = 0
|
|
1699
|
+
while (cursor < css.length) {
|
|
1700
|
+
const head = css.indexOf(guard, cursor)
|
|
1701
|
+
if (head === -1) {
|
|
1702
|
+
out += css.slice(cursor)
|
|
1703
|
+
break
|
|
1704
|
+
}
|
|
1705
|
+
out += css.slice(cursor, head)
|
|
1706
|
+
const brace = css.indexOf('{', head)
|
|
1707
|
+
if (brace === -1) {
|
|
1708
|
+
out += css.slice(head)
|
|
1709
|
+
break
|
|
1710
|
+
}
|
|
1711
|
+
const blockEnd = findMatchingClose(css, brace + 1)
|
|
1712
|
+
if (blockEnd === -1) {
|
|
1713
|
+
out += css.slice(head)
|
|
1714
|
+
break
|
|
1715
|
+
}
|
|
1716
|
+
const inner = css.slice(brace + 1, blockEnd)
|
|
1717
|
+
// Only unwrap when the gated declaration substitutes a USER theme
|
|
1718
|
+
// token (`var(--color-…)`). Tailwind also gates `--tw-*` internal
|
|
1719
|
+
// composers (shadow color, ring color, …) on the same supports
|
|
1720
|
+
// clause; their outer fallback is the optimized hex/oklch value
|
|
1721
|
+
// the parser's own composed-prop pass needs (`applyComposedShadow`
|
|
1722
|
+
// reads `--tw-shadow-color` from the rule's local vars). Unwrapping
|
|
1723
|
+
// them would replace the resolvable color with an unresolvable
|
|
1724
|
+
// `color-mix(... var(--tw-shadow-alpha), transparent)` text and
|
|
1725
|
+
// break the composed-shadow path.
|
|
1726
|
+
// Keep the gate intact for non-themed colors — the outer fallback
|
|
1727
|
+
// wins, which is what Tailwind intended.
|
|
1728
|
+
out += inner.includes('var(--color-') ? inner : css.slice(head, blockEnd + 1)
|
|
1729
|
+
cursor = blockEnd + 1
|
|
1730
|
+
}
|
|
1731
|
+
return out
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Extract every `--name: value` declaration from the `:root` blocks in
|
|
1736
|
+
* Tailwind's compiled CSS into a flat map.
|
|
1737
|
+
* @param css Tailwind-compiled CSS.
|
|
1738
|
+
* @returns Map of custom-property name → resolved value.
|
|
1739
|
+
*/
|
|
1621
1740
|
function extractRootCustomProperties(css: string): Map<string, string> {
|
|
1622
1741
|
const out = new Map<string, string>()
|
|
1623
1742
|
let cursor = 0
|
|
@@ -48,6 +48,20 @@ function letterSpacingToEntries(value: LcDeclaration['value']): readonly RNEntry
|
|
|
48
48
|
return [['letterSpacing', unit === 'px' ? px : px * 16]]
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Lower a CSS `text-align` keyword to one RN's `textAlign` accepts. RN
|
|
53
|
+
* has no logical `start`/`end`, so map them to physical sides (LTR
|
|
54
|
+
* default); every other keyword (left/right/center/justify/auto) is
|
|
55
|
+
* already valid and passes through.
|
|
56
|
+
* @param align CSS text-align keyword.
|
|
57
|
+
* @returns RN-valid textAlign keyword.
|
|
58
|
+
*/
|
|
59
|
+
function physicalTextAlign(align: string): string {
|
|
60
|
+
if (align === 'start') return 'left'
|
|
61
|
+
if (align === 'end') return 'right'
|
|
62
|
+
return align
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
/**
|
|
52
66
|
* Dispatch typography declarations rnwind cares about (text-align,
|
|
53
67
|
* text-transform, text-decoration-line, line-height, letter-spacing,
|
|
@@ -59,7 +73,7 @@ function letterSpacingToEntries(value: LcDeclaration['value']): readonly RNEntry
|
|
|
59
73
|
export function dispatchTypographyDeclaration(decl: LcDeclaration): readonly RNEntry[] | null {
|
|
60
74
|
switch (decl.property) {
|
|
61
75
|
case 'text-align': {
|
|
62
|
-
return [['textAlign', decl.value]]
|
|
76
|
+
return [['textAlign', physicalTextAlign(String(decl.value))]]
|
|
63
77
|
}
|
|
64
78
|
case 'text-transform': {
|
|
65
79
|
return [['textTransform', decl.value.case ?? 'none']]
|
|
@@ -309,25 +309,84 @@ export interface AtomSerializedEntry {
|
|
|
309
309
|
export type AtomSerializedCache = Map<string, AtomSerializedEntry>
|
|
310
310
|
|
|
311
311
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
312
|
+
* Pre-serialize every non-empty variant value, reusing the per-atom
|
|
313
|
+
* cache where present. Result drives both the scheme-uniform check
|
|
314
|
+
* AND the per-variant emission loop downstream.
|
|
315
|
+
* @param atom Atom name.
|
|
316
|
+
* @param schemed Parser-produced schemed bucket.
|
|
317
|
+
* @param variants Variant scheme names in deterministic order.
|
|
318
|
+
* @param keyframes Keyframes available to inline.
|
|
319
|
+
* @param cached Cached entry for this atom (when ref-stable).
|
|
320
|
+
* @returns variantName → serialized text.
|
|
321
|
+
*/
|
|
322
|
+
function buildVariantTexts(
|
|
323
|
+
atom: string,
|
|
324
|
+
schemed: SchemedStyle,
|
|
325
|
+
variants: readonly string[],
|
|
326
|
+
keyframes: ReadonlyMap<string, KeyframeBlock>,
|
|
327
|
+
cached: AtomSerializedEntry | undefined,
|
|
328
|
+
): Map<string, string> {
|
|
329
|
+
const out = new Map<string, string>()
|
|
330
|
+
for (const variant of variants) {
|
|
331
|
+
const own = (schemed as Readonly<Record<string, RNStyle>>)[variant]
|
|
332
|
+
if (!isNonEmptyStyle(own)) continue
|
|
333
|
+
const text = cached?.variants.get(variant) ?? prepareAtomValue(atom, own, keyframes)
|
|
334
|
+
out.set(variant, text)
|
|
335
|
+
}
|
|
336
|
+
return out
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Decide whether a (no-base) atom should be promoted to common because
|
|
341
|
+
* every declared variant resolves to the same value. This is the
|
|
342
|
+
* scheme-uniform case: `flex`, `p-4`, `absolute` all carry no theme
|
|
343
|
+
* variables, so Phase-1 fills every variant bucket identically and
|
|
344
|
+
* leaves `base` empty — without this collapse they'd be duplicated
|
|
345
|
+
* across every scheme file.
|
|
346
|
+
*
|
|
347
|
+
* The variant-prefix check is what keeps a real scheme-gated atom
|
|
348
|
+
* (`dark:bg-indigo-800`) out of common in a single-variant project
|
|
349
|
+
* (where its 1 bucket would otherwise look "uniform" by definition).
|
|
350
|
+
* @param atom Atom name (checked for `<variant>:` prefix).
|
|
351
|
+
* @param variants Declared variant scheme names.
|
|
352
|
+
* @param variantTexts Serialized variant values.
|
|
353
|
+
* @param canonicalText Serialized canonical (common) value.
|
|
354
|
+
* @returns Whether the atom is uniform across every declared variant.
|
|
355
|
+
*/
|
|
356
|
+
function isSchemeUniform(
|
|
357
|
+
atom: string,
|
|
358
|
+
variants: readonly string[],
|
|
359
|
+
variantTexts: ReadonlyMap<string, string>,
|
|
360
|
+
canonicalText: string,
|
|
361
|
+
): boolean {
|
|
362
|
+
if (variants.length === 0 || variantTexts.size !== variants.length) return false
|
|
363
|
+
if (variants.some((variant) => atom.startsWith(`${variant}:`))) return false
|
|
364
|
+
for (const text of variantTexts.values()) {
|
|
365
|
+
if (text !== canonicalText) return false
|
|
366
|
+
}
|
|
367
|
+
return true
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Serialize one atom's canonical + variant-diff entries, honouring the
|
|
372
|
+
* per-atom cache. Returns the number of cache MISSES this atom incurred
|
|
373
|
+
* (0 when canonical was cached AND every needed variant was cached;
|
|
374
|
+
* 1 when anything had to be re-stringified).
|
|
318
375
|
*
|
|
319
|
-
*
|
|
320
|
-
* bucket:
|
|
376
|
+
* Three paths gated on whether the parser produced a non-empty `base`
|
|
377
|
+
* bucket and whether the variants converge:
|
|
321
378
|
* - **Themed atom (base present)**: canonical goes to `common`, each
|
|
322
379
|
* variant whose own value diverges from canonical writes the diff
|
|
323
|
-
* into its own scheme file. `lookupAtom`
|
|
380
|
+
* into its own scheme file. `lookupAtom` finds the variant's
|
|
324
381
|
* override or falls through to common.
|
|
325
|
-
* - **Scheme-
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
382
|
+
* - **Scheme-uniform atom (base empty, every variant identical)**:
|
|
383
|
+
* promoted to `common` once — the parser's Phase-1 fills every
|
|
384
|
+
* variant bucket with the same value for utilities like `flex` /
|
|
385
|
+
* `p-4` / `absolute` that don't reference theme variables.
|
|
386
|
+
* - **Scheme-gated atom (base empty, prefixed name like `dark:foo`,
|
|
387
|
+
* or variants diverge)**: each populated variant writes the value
|
|
388
|
+
* into its own scheme file directly; common stays empty so the
|
|
389
|
+
* runtime fallback can't leak the variant style into other schemes.
|
|
331
390
|
* @param atom Atom name.
|
|
332
391
|
* @param schemed Parser-produced schemed bucket for the atom.
|
|
333
392
|
* @param canonical Canonical RN style for `common`.
|
|
@@ -351,23 +410,22 @@ function collectAtomEntries(
|
|
|
351
410
|
const cached = cache?.get(atom)
|
|
352
411
|
const hit = cached?.styleRef === schemed
|
|
353
412
|
const baseEntry = (schemed as Readonly<Record<string, RNStyle>>)[BASE_SCHEME]
|
|
354
|
-
const
|
|
413
|
+
const hasBase = isNonEmptyStyle(baseEntry)
|
|
355
414
|
const canonicalText = hit ? cached.canonical : prepareAtomValue(atom, canonical, keyframes)
|
|
356
|
-
|
|
415
|
+
const variantTexts = buildVariantTexts(atom, schemed, variants, keyframes, hit ? cached : undefined)
|
|
416
|
+
const goesToCommon = hasBase || isSchemeUniform(atom, variants, variantTexts, canonicalText)
|
|
417
|
+
|
|
418
|
+
if (goesToCommon) commonEntries.push([atom, canonicalText])
|
|
419
|
+
|
|
357
420
|
const entry: AtomSerializedEntry = hit
|
|
358
421
|
? cached
|
|
359
|
-
: { styleRef: schemed, canonical: canonicalText, variants: new Map
|
|
422
|
+
: { styleRef: schemed, canonical: canonicalText, variants: new Map(variantTexts) }
|
|
360
423
|
if (!hit) cache?.set(atom, entry)
|
|
361
424
|
|
|
362
425
|
for (const variant of variants) {
|
|
363
|
-
const
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
if (ownText === undefined) {
|
|
367
|
-
ownText = prepareAtomValue(atom, own, keyframes)
|
|
368
|
-
entry.variants.set(variant, ownText)
|
|
369
|
-
}
|
|
370
|
-
if (!isSchemeGated && ownText === canonicalText) continue
|
|
426
|
+
const ownText = variantTexts.get(variant)
|
|
427
|
+
if (ownText === undefined) continue
|
|
428
|
+
if (goesToCommon && ownText === canonicalText) continue
|
|
371
429
|
variantEntries[variant].push([atom, ownText])
|
|
372
430
|
}
|
|
373
431
|
return hit ? 0 : 1
|