rnwind 0.0.11 → 0.0.12
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/normalize-classname.cjs +3 -1
- package/lib/cjs/core/normalize-classname.cjs.map +1 -1
- package/lib/cjs/core/parser/border-dispatcher.cjs +20 -10
- package/lib/cjs/core/parser/border-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/color-properties-dispatcher.cjs +7 -5
- package/lib/cjs/core/parser/color-properties-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/color.cjs +194 -10
- package/lib/cjs/core/parser/color.cjs.map +1 -1
- package/lib/cjs/core/parser/color.d.ts +18 -3
- package/lib/cjs/core/parser/declaration.cjs +62 -4
- package/lib/cjs/core/parser/declaration.cjs.map +1 -1
- package/lib/cjs/core/parser/layout-dispatcher.cjs +32 -2
- package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/shorthand.cjs +10 -3
- package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
- package/lib/cjs/core/parser/tokens.cjs +9 -0
- package/lib/cjs/core/parser/tokens.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.cjs +6 -0
- package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
- package/lib/cjs/core/parser/typography-dispatcher.cjs +15 -8
- package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/style-builder/union-builder.cjs +81 -2
- package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
- package/lib/cjs/core/style-builder/union-builder.d.ts +28 -0
- package/lib/cjs/metro/state.cjs +74 -13
- package/lib/cjs/metro/state.cjs.map +1 -1
- package/lib/cjs/metro/state.d.ts +18 -0
- package/lib/cjs/metro/transformer.cjs +10 -4
- package/lib/cjs/metro/transformer.cjs.map +1 -1
- package/lib/cjs/metro/with-config.cjs +57 -0
- package/lib/cjs/metro/with-config.cjs.map +1 -1
- package/lib/cjs/metro/with-config.d.ts +12 -0
- package/lib/cjs/metro/wrap-imports.cjs +36 -1
- package/lib/cjs/metro/wrap-imports.cjs.map +1 -1
- package/lib/cjs/runtime/hooks/use-scheme.cjs +14 -7
- package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
- package/lib/cjs/runtime/resolve.cjs +6 -2
- package/lib/cjs/runtime/resolve.cjs.map +1 -1
- package/lib/cjs/runtime/resolve.d.ts +5 -1
- package/lib/esm/core/normalize-classname.mjs +3 -1
- package/lib/esm/core/normalize-classname.mjs.map +1 -1
- package/lib/esm/core/parser/border-dispatcher.mjs +21 -11
- package/lib/esm/core/parser/border-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/color-properties-dispatcher.mjs +8 -6
- package/lib/esm/core/parser/color-properties-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/color.d.ts +18 -3
- package/lib/esm/core/parser/color.mjs +195 -12
- package/lib/esm/core/parser/color.mjs.map +1 -1
- package/lib/esm/core/parser/declaration.mjs +63 -5
- package/lib/esm/core/parser/declaration.mjs.map +1 -1
- package/lib/esm/core/parser/layout-dispatcher.mjs +32 -2
- package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/shorthand.mjs +11 -4
- package/lib/esm/core/parser/shorthand.mjs.map +1 -1
- package/lib/esm/core/parser/tokens.mjs +10 -1
- package/lib/esm/core/parser/tokens.mjs.map +1 -1
- package/lib/esm/core/parser/tw-parser.mjs +6 -0
- package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs +15 -8
- package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
- package/lib/esm/core/style-builder/union-builder.d.ts +28 -0
- package/lib/esm/core/style-builder/union-builder.mjs +82 -3
- package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
- package/lib/esm/metro/state.d.ts +18 -0
- package/lib/esm/metro/state.mjs +75 -14
- package/lib/esm/metro/state.mjs.map +1 -1
- package/lib/esm/metro/transformer.mjs +10 -4
- package/lib/esm/metro/transformer.mjs.map +1 -1
- package/lib/esm/metro/with-config.d.ts +12 -0
- package/lib/esm/metro/with-config.mjs +58 -2
- package/lib/esm/metro/with-config.mjs.map +1 -1
- package/lib/esm/metro/wrap-imports.mjs +36 -1
- package/lib/esm/metro/wrap-imports.mjs.map +1 -1
- package/lib/esm/runtime/hooks/use-scheme.mjs +14 -7
- package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
- package/lib/esm/runtime/resolve.d.ts +5 -1
- package/lib/esm/runtime/resolve.mjs +6 -2
- package/lib/esm/runtime/resolve.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/normalize-classname.ts +4 -1
- package/src/core/parser/border-dispatcher.ts +22 -11
- package/src/core/parser/color-properties-dispatcher.ts +7 -5
- package/src/core/parser/color.ts +182 -11
- package/src/core/parser/declaration.ts +61 -5
- package/src/core/parser/layout-dispatcher.ts +34 -2
- package/src/core/parser/shorthand.ts +9 -3
- package/src/core/parser/tokens.ts +10 -1
- package/src/core/parser/tw-parser.ts +5 -0
- package/src/core/parser/typography-dispatcher.ts +15 -6
- package/src/core/style-builder/union-builder.ts +83 -3
- package/src/metro/state.ts +117 -12
- package/src/metro/transformer.ts +9 -4
- package/src/metro/with-config.ts +59 -1
- package/src/metro/wrap-imports.ts +36 -1
- package/src/runtime/hooks/use-scheme.ts +13 -6
- package/src/runtime/resolve.ts +6 -2
package/src/core/parser/color.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CssColor, LABColor } from 'lightningcss'
|
|
2
|
-
import { formatHex, rgb as culoriRgb } from 'culori'
|
|
2
|
+
import { formatHex, interpolate, rgb as culoriRgb } from 'culori'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Clamp a 0-255 float to the integer byte range RN color strings accept.
|
|
@@ -136,35 +136,196 @@ function xyzToHex(color: { type: 'xyz-d50' | 'xyz-d65'; x: number; y: number; z:
|
|
|
136
136
|
return withAlpha(formatHex({ mode, x: color.x, y: color.y, z: color.z }) ?? null, color.alpha)
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Map a CSS `color-mix(in <space>, …)` interpolation space to the culori mode
|
|
141
|
+
* key culori's {@link interpolate} understands. `srgb` is culori's `rgb`;
|
|
142
|
+
* `srgb-linear` is `lrgb`; the lab/lch/oklab/oklch/hsl/hwb spaces share their
|
|
143
|
+
* CSS name. Unknown spaces fall back to `rgb` so a mix still resolves to a
|
|
144
|
+
* concrete color rather than leaking the raw expression.
|
|
145
|
+
* @param space Lowercased interpolation-space token (after `in `).
|
|
146
|
+
* @returns culori interpolation mode key.
|
|
147
|
+
*/
|
|
148
|
+
function colorMixModeFor(space: string): string {
|
|
149
|
+
if (space === 'srgb') return 'rgb'
|
|
150
|
+
if (space === 'srgb-linear') return 'lrgb'
|
|
151
|
+
const known = new Set(['oklab', 'oklch', 'lab', 'lch', 'hsl', 'hwb', 'xyz', 'xyz-d50', 'xyz-d65'])
|
|
152
|
+
return known.has(space) ? space : 'rgb'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Split a `color-mix()` argument list at top-level commas (parens-aware) so a
|
|
157
|
+
* nested `rgb(0, 0, 0)` color slot doesn't fragment the split.
|
|
158
|
+
* @param body Text between the outer `color-mix(` parentheses.
|
|
159
|
+
* @returns Comma-separated argument fragments (trimmed).
|
|
160
|
+
*/
|
|
161
|
+
function splitColorMixArgs(body: string): string[] {
|
|
162
|
+
const parts: string[] = []
|
|
163
|
+
let depth = 0
|
|
164
|
+
let start = 0
|
|
165
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
166
|
+
const ch = body[index]
|
|
167
|
+
if (ch === '(') depth += 1
|
|
168
|
+
else if (ch === ')') depth -= 1
|
|
169
|
+
else if (ch === ',' && depth === 0) {
|
|
170
|
+
parts.push(body.slice(start, index).trim())
|
|
171
|
+
start = index + 1
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
parts.push(body.slice(start).trim())
|
|
175
|
+
return parts
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Peel an optional trailing `<num>%` weight off a `color-mix()` color slot.
|
|
180
|
+
* `#ff0000 50%` → `{ color: '#ff0000', weight: 0.5 }`; a bare color → weight
|
|
181
|
+
* `null` (caller fills the complement / defaults to 50/50).
|
|
182
|
+
* @param slot One color argument (color text, optionally suffixed with a percentage).
|
|
183
|
+
* @returns Color text plus its 0–1 weight, or null weight when unspecified.
|
|
184
|
+
*/
|
|
185
|
+
function parseColorMixSlot(slot: string): { color: string; weight: number | null } {
|
|
186
|
+
const trimmed = slot.trim()
|
|
187
|
+
if (!trimmed.endsWith('%')) return { color: trimmed, weight: null }
|
|
188
|
+
// End-anchored `<num>%` matcher (no leading `.*?` — avoids the super-linear
|
|
189
|
+
// backtracking ESLint flags). Split the color off at the last whitespace
|
|
190
|
+
// before the percentage token.
|
|
191
|
+
const pct = COLOR_MIX_SLOT_PCT.exec(trimmed)
|
|
192
|
+
if (!pct) return { color: trimmed, weight: null }
|
|
193
|
+
const color = trimmed.slice(0, pct.index).trim()
|
|
194
|
+
if (color.length === 0) return { color: trimmed, weight: null }
|
|
195
|
+
const weight = Number(pct[1]) / 100
|
|
196
|
+
return { color, weight: Number.isFinite(weight) ? weight : null }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** End-anchored `<num>%` matcher for slicing a color-mix slot's weight off its right edge. No backtracking. */
|
|
200
|
+
|
|
201
|
+
const COLOR_MIX_SLOT_PCT = /\s(-?\d+(?:\.\d+)?)%$/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve a two-color CSS `color-mix(in <space>, A [p1%], B [p2%])` to a
|
|
205
|
+
* concrete sRGB color via culori's {@link interpolate}. CSS weight rules:
|
|
206
|
+
* with one percentage the other fills the complement; with none it is 50/50;
|
|
207
|
+
* with both, the interpolation point is `p2 / (p1 + p2)`. RN can't evaluate
|
|
208
|
+
* `color-mix()` at paint time, so this is the only path that lowers it.
|
|
209
|
+
* @param text Trimmed CSS value beginning with `color-mix(`.
|
|
210
|
+
* @returns sRGB hex/rgba string, or null when the shape/colors can't resolve.
|
|
211
|
+
*/
|
|
212
|
+
function resolveColorMix(text: string): string | null {
|
|
213
|
+
if (!text.endsWith(')')) return null
|
|
214
|
+
const open = text.indexOf('(')
|
|
215
|
+
if (open === -1) return null
|
|
216
|
+
const args = splitColorMixArgs(text.slice(open + 1, -1))
|
|
217
|
+
if (args.length !== 3) return null
|
|
218
|
+
const spaceClause = args[0]!.toLowerCase()
|
|
219
|
+
if (!spaceClause.startsWith('in ')) return null
|
|
220
|
+
const mode = colorMixModeFor(spaceClause.slice(3).trim())
|
|
221
|
+
const first = parseColorMixSlot(args[1]!)
|
|
222
|
+
const second = parseColorMixSlot(args[2]!)
|
|
223
|
+
if (first.color.length === 0 || second.color.length === 0) return null
|
|
224
|
+
const point = colorMixPoint(first.weight, second.weight)
|
|
225
|
+
if (point === null) return null
|
|
226
|
+
try {
|
|
227
|
+
const mixed = interpolate([first.color, second.color], mode as never)(point)
|
|
228
|
+
if (!mixed) return null
|
|
229
|
+
const back = culoriRgb(mixed) as { r?: number; g?: number; b?: number; alpha?: number } | undefined
|
|
230
|
+
if (!back || ![back.r, back.g, back.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
|
|
231
|
+
const alpha = typeof back.alpha === 'number' ? back.alpha : 1
|
|
232
|
+
return rgbIntsToString(clampByte(back.r! * 255), clampByte(back.g! * 255), clampByte(back.b! * 255), alpha)
|
|
233
|
+
} catch {
|
|
234
|
+
// culori threw on an unparseable color slot — drop rather than leak the raw string.
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Compute the 0–1 interpolation point (weight of the SECOND color) from the
|
|
241
|
+
* two optional `color-mix()` weights, applying CSS normalization.
|
|
242
|
+
* @param firstWeight 0–1 weight of color A, or null when unspecified.
|
|
243
|
+
* @param secondWeight 0–1 weight of color B, or null when unspecified.
|
|
244
|
+
* @returns Interpolation point in `[0, 1]`, or null when both weights are 0.
|
|
245
|
+
*/
|
|
246
|
+
function colorMixPoint(firstWeight: number | null, secondWeight: number | null): number | null {
|
|
247
|
+
if (firstWeight === null && secondWeight === null) return 0.5
|
|
248
|
+
if (firstWeight !== null && secondWeight === null) return 1 - firstWeight
|
|
249
|
+
if (firstWeight === null && secondWeight !== null) return secondWeight
|
|
250
|
+
const sum = firstWeight! + secondWeight!
|
|
251
|
+
if (sum === 0) return null
|
|
252
|
+
return secondWeight! / sum
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* CSS-wide cascade keywords that resolve a property against the inherited /
|
|
257
|
+
* initial / previous-layer value at paint time. React Native has NO color
|
|
258
|
+
* cascade — there is no inherited `color` for an arbitrary style prop and no
|
|
259
|
+
* cascade layers — so as a `color` / `backgroundColor` / `borderColor` value
|
|
260
|
+
* every one of these reaches RN as an invalid color string. `currentColor`
|
|
261
|
+
* belongs here too: it resolves to the element's inherited `color`, which RN
|
|
262
|
+
* never threads into other color props. The color path must DROP these (omit
|
|
263
|
+
* the key) rather than leak the keyword. NOTE: `transparent` is NOT here — it
|
|
264
|
+
* is a real color that {@link cssColorToString} / {@link normalizeColorString}
|
|
265
|
+
* lower to `rgba(0, 0, 0, 0)`, which RN paints correctly.
|
|
266
|
+
*/
|
|
267
|
+
const CSS_WIDE_COLOR_KEYWORDS: ReadonlySet<string> = new Set([
|
|
268
|
+
'currentcolor',
|
|
269
|
+
'inherit',
|
|
270
|
+
'initial',
|
|
271
|
+
'unset',
|
|
272
|
+
'revert',
|
|
273
|
+
'revert-layer',
|
|
274
|
+
])
|
|
275
|
+
|
|
139
276
|
/**
|
|
140
277
|
* Modern CSS color functions RN's native view manager can't paint —
|
|
141
278
|
* everything else (hex, `rgb()`/`rgba()`, `hsl()`/`hsla()`, named colors,
|
|
142
|
-
* `transparent
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
279
|
+
* `transparent`) RN reads directly and must pass through untouched. The
|
|
280
|
+
* CSS-wide cascade keywords (`currentColor`, `inherit`, …) are NOT readable —
|
|
281
|
+
* they have no RN equivalent and are dropped via {@link isCssWideColorKeyword}.
|
|
282
|
+
* Custom `@theme` tokens reach the parser as `var(--color-x)` (only the default
|
|
283
|
+
* palette is `theme(inline)`-d), so they flow through the unparsed-string path
|
|
284
|
+
* where the typed {@link cssColorToString} never runs — this is the one place
|
|
285
|
+
* that lowers their wide-gamut values to sRGB. `color-mix(` is in the list too,
|
|
286
|
+
* but it takes the dedicated {@link resolveColorMix} path — culori's `rgb()`
|
|
287
|
+
* parser can't read it.
|
|
147
288
|
*/
|
|
148
|
-
const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(']
|
|
289
|
+
const RN_UNREADABLE_COLOR_PREFIXES: readonly string[] = ['oklch(', 'oklab(', 'lab(', 'lch(', 'color(', 'hwb(', 'color-mix(']
|
|
149
290
|
|
|
150
291
|
/**
|
|
151
292
|
* Lower a wide-gamut / modern CSS color STRING (`oklch(…)`, `lab(…)`,
|
|
152
|
-
* `color(display-p3 …)`, …) to an sRGB hex/rgba string RN can
|
|
153
|
-
* `null` for anything RN already understands (hex, rgb, hsl,
|
|
154
|
-
* caller keeps the original text — only the unrepresentable
|
|
293
|
+
* `color(display-p3 …)`, `color-mix(…)`) to an sRGB hex/rgba string RN can
|
|
294
|
+
* paint. Returns `null` for anything RN already understands (hex, rgb, hsl,
|
|
295
|
+
* named) so the caller keeps the original text — only the unrepresentable
|
|
296
|
+
* forms convert. `color-mix()` is resolved via culori's interpolator; when it
|
|
297
|
+
* (or any other modern form) can't resolve, returns null so the caller DROPS
|
|
298
|
+
* the value rather than leaking the raw, RN-unreadable string.
|
|
155
299
|
* Mirrors {@link cssColorToString}'s culori lowering for the string path.
|
|
156
300
|
* @param text Resolved CSS color text (post theme-var substitution).
|
|
157
301
|
* @returns sRGB color string, or `null` when no conversion is needed/possible.
|
|
158
302
|
*/
|
|
159
303
|
export function normalizeColorString(text: string): string | null {
|
|
160
|
-
const
|
|
304
|
+
const trimmed = text.trim()
|
|
305
|
+
const lower = trimmed.toLowerCase()
|
|
161
306
|
if (!RN_UNREADABLE_COLOR_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null
|
|
307
|
+
if (lower.startsWith('color-mix(')) return resolveColorMix(trimmed)
|
|
162
308
|
const parsed = culoriRgb(text)
|
|
163
309
|
if (!parsed || ![parsed.r, parsed.g, parsed.b].every((v) => typeof v === 'number' && Number.isFinite(v))) return null
|
|
164
310
|
const alpha = typeof parsed.alpha === 'number' ? parsed.alpha : 1
|
|
165
311
|
return rgbIntsToString(clampByte(parsed.r * 255), clampByte(parsed.g * 255), clampByte(parsed.b * 255), alpha)
|
|
166
312
|
}
|
|
167
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Whether a resolved color STRING is a CSS-wide cascade keyword
|
|
316
|
+
* (`currentColor`, `inherit`, `initial`, `unset`, `revert`, `revert-layer`)
|
|
317
|
+
* with no React Native equivalent. RN has no color cascade, so the color path
|
|
318
|
+
* must DROP (omit the key) when this is true rather than emit the keyword —
|
|
319
|
+
* RN would otherwise receive an invalid color string and render nothing.
|
|
320
|
+
* `transparent` is NOT a cascade keyword: it is a concrete color the converters
|
|
321
|
+
* lower to `rgba(0, 0, 0, 0)`, so it returns false here and resolves normally.
|
|
322
|
+
* @param text Resolved color text (post theme-var substitution / typed-color stringification).
|
|
323
|
+
* @returns True when the value is an RN-unrepresentable CSS-wide keyword.
|
|
324
|
+
*/
|
|
325
|
+
export function isCssWideColorKeyword(text: string): boolean {
|
|
326
|
+
return CSS_WIDE_COLOR_KEYWORDS.has(text.trim().toLowerCase())
|
|
327
|
+
}
|
|
328
|
+
|
|
168
329
|
/**
|
|
169
330
|
* Convert a lightningcss `CssColor` to an RN-safe color string. RGB
|
|
170
331
|
* passes through unchanged. LAB / LCH / OKLAB / OKLCH / `color(xyz-…)`
|
|
@@ -213,6 +374,16 @@ export function cssColorToString(color: CssColor): string {
|
|
|
213
374
|
return 'currentColor'
|
|
214
375
|
}
|
|
215
376
|
case 'light-dark': {
|
|
377
|
+
// `light-dark(L, D)` is a RUNTIME CSS function — the browser picks the
|
|
378
|
+
// branch from the element's `color-scheme`. rnwind has no runtime CSS
|
|
379
|
+
// evaluation, and the active scheme is NOT threaded into this typed
|
|
380
|
+
// converter (it takes a bare `CssColor`, and every one of its ~15 call
|
|
381
|
+
// sites — border / shorthand / gradient / declaration dispatchers — calls
|
|
382
|
+
// it without a scheme). Scheme resolution instead happens UPSTREAM, at the
|
|
383
|
+
// CSS-block walk (`@custom-variant` + `.dark {}` selectors in
|
|
384
|
+
// theme-vars.ts), which compiles a separate atom + var table per scheme.
|
|
385
|
+
// So the `.light` branch is the correct compile-time default here; the
|
|
386
|
+
// dark value is carried by the scheme-specific atom, not by this function.
|
|
216
387
|
return cssColorToString(color.light)
|
|
217
388
|
}
|
|
218
389
|
default: {
|
|
@@ -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, normalizeColorString } from './color'
|
|
4
|
+
import { cssColorToString, isCssWideColorKeyword, normalizeColorString } from './color'
|
|
5
5
|
import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue, sizeLikeToValue } from './length'
|
|
6
6
|
import {
|
|
7
7
|
expandBorderColor,
|
|
@@ -57,6 +57,10 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
|
|
|
57
57
|
'clear',
|
|
58
58
|
'table-layout',
|
|
59
59
|
'caption-side',
|
|
60
|
+
// Web table-model props with no RN equivalent. `border-spacing` otherwise
|
|
61
|
+
// reaches the generic fallback and leaks an unresolved `calc(0.25rem * N)`.
|
|
62
|
+
'border-spacing',
|
|
63
|
+
'border-collapse',
|
|
60
64
|
'transform-style',
|
|
61
65
|
'background-blend-mode',
|
|
62
66
|
'scroll-behavior',
|
|
@@ -73,6 +77,20 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
|
|
|
73
77
|
'field-sizing',
|
|
74
78
|
'forced-color-adjust',
|
|
75
79
|
'text-shadow',
|
|
80
|
+
// Web-only KEYS RN has no style prop for. `order` leaks through the negative
|
|
81
|
+
// variant (`-order-1` → `order: calc(1 * -1)` unparsed → resolves to `-1`);
|
|
82
|
+
// the positive `order-*` already drops since no typed branch claims it. Adding
|
|
83
|
+
// it here drops BOTH signs. `isolation` (`isolate` / `isolation-auto`) reaches
|
|
84
|
+
// the `custom` path as `isolation: isolate|auto` — also no RN equivalent.
|
|
85
|
+
'order',
|
|
86
|
+
'isolation',
|
|
87
|
+
// `normal-nums` reaches the `custom` path as `font-variant-numeric: normal`
|
|
88
|
+
// and leaked the non-RN key `fontVariantNumeric`. RN expresses numeric
|
|
89
|
+
// variants via the `fontVariant` array, not this property — drop it. (The
|
|
90
|
+
// `tabular-nums`/`oldstyle-nums`/… utilities carry their token in dropped
|
|
91
|
+
// `--tw-numeric-*` vars and already resolve to {}; mapping those to
|
|
92
|
+
// `fontVariant` is a tracked future enhancement, not a leak.)
|
|
93
|
+
'font-variant-numeric',
|
|
76
94
|
'touch-action',
|
|
77
95
|
'backdrop-filter',
|
|
78
96
|
'-webkit-backdrop-filter',
|
|
@@ -82,6 +100,24 @@ const RN_UNSUPPORTED_PROPERTIES: ReadonlySet<string> = new Set([
|
|
|
82
100
|
'-moz-osx-font-smoothing',
|
|
83
101
|
])
|
|
84
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Valid value sets for RN enum style props (keyed by the camelCase RN key).
|
|
105
|
+
* A value outside its prop's set is RN-invalid even when the string itself
|
|
106
|
+
* looks clean — RN ignores or warns on it (`position: 'fixed'`, `display:
|
|
107
|
+
* 'contents'`, `justifyContent: 'stretch' | 'baseline'`, `alignContent:
|
|
108
|
+
* 'normal'`). This is the dimension the leak-shape (`var(`/`calc(`/NaN) check
|
|
109
|
+
* misses. Both the typed `display` / `position` branches AND the generic
|
|
110
|
+
* unparsed fallback consult this — Tailwind routes some keyword-only values
|
|
111
|
+
* (`justify-content: baseline`) through the unparsed channel, which would
|
|
112
|
+
* otherwise emit them via `kebabToCamel` with no enum awareness.
|
|
113
|
+
*/
|
|
114
|
+
const RN_ENUM_VALUES: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
115
|
+
position: new Set(['absolute', 'relative', 'static']),
|
|
116
|
+
display: new Set(['flex', 'none']),
|
|
117
|
+
justifyContent: new Set(['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly']),
|
|
118
|
+
alignContent: new Set(['flex-start', 'flex-end', 'center', 'stretch', 'space-between', 'space-around', 'space-evenly']),
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
/** CSS single-sided logical-inline property → RN writing-direction Yoga key. */
|
|
86
122
|
const LOGICAL_INLINE_TO_RN: Record<string, string> = {
|
|
87
123
|
'margin-inline-start': 'marginStart',
|
|
@@ -245,6 +281,10 @@ function unparsedToEntries(
|
|
|
245
281
|
// space (outside parens — `rgb(1 2 3)` keeps its inner spaces) means it's a
|
|
246
282
|
// multi-token shorthand, not a color: drop it.
|
|
247
283
|
if (hasTopLevelSpace(coerced)) return []
|
|
284
|
+
// CSS-wide cascade keywords (`inherit`, `currentColor`, `initial`, `unset`,
|
|
285
|
+
// `revert`, `revert-layer`) have no RN equivalent — RN has no color
|
|
286
|
+
// cascade. Drop rather than leak an invalid color string to RN.
|
|
287
|
+
if (isCssWideColorKeyword(coerced)) return []
|
|
248
288
|
// Lower modern color spaces (`oklch(…)`, `lab(…)`, `color(p3 …)`) that
|
|
249
289
|
// RN can't paint to sRGB; hex/rgb/hsl/named pass through unchanged.
|
|
250
290
|
const color = normalizeColorString(coerced) ?? coerced
|
|
@@ -254,7 +294,14 @@ function unparsedToEntries(
|
|
|
254
294
|
if (sides) return sides.map((key): RNEntry => [key, color])
|
|
255
295
|
return [[kebabToCamel(property), color]]
|
|
256
296
|
}
|
|
257
|
-
|
|
297
|
+
const camelKey = kebabToCamel(property)
|
|
298
|
+
// Enum props whose value Tailwind sometimes routes through the unparsed
|
|
299
|
+
// channel (`justify-content: baseline` → `justifyContent: 'baseline'`),
|
|
300
|
+
// bypassing the typed dispatcher's keyword map. RN rejects values outside
|
|
301
|
+
// the prop's set, so gate them here exactly like the typed branches do.
|
|
302
|
+
const enumValues = RN_ENUM_VALUES[camelKey]
|
|
303
|
+
if (enumValues && typeof coerced === 'string' && !enumValues.has(coerced)) return []
|
|
304
|
+
return [[camelKey, coerced]]
|
|
258
305
|
}
|
|
259
306
|
|
|
260
307
|
/**
|
|
@@ -294,7 +341,12 @@ export function declarationToRnEntries(decl: LcDeclaration, themeVars?: Readonly
|
|
|
294
341
|
// `background-color` narrows to `CssColor | 'background'` — the
|
|
295
342
|
// literal keyword means UA default. Skip the keyword.
|
|
296
343
|
if (typeof decl.value === 'string') return []
|
|
297
|
-
|
|
344
|
+
const colorString = cssColorToString(decl.value)
|
|
345
|
+
// `currentColor` (lightningcss `{type:'currentcolor'}`) and any other
|
|
346
|
+
// CSS-wide cascade keyword have no RN equivalent — drop instead of
|
|
347
|
+
// leaking the keyword string to RN.
|
|
348
|
+
if (isCssWideColorKeyword(colorString)) return []
|
|
349
|
+
return [[kebabToCamel(decl.property), colorString]]
|
|
298
350
|
}
|
|
299
351
|
case 'border-color': {
|
|
300
352
|
return expandBorderColor(decl.value)
|
|
@@ -370,10 +422,14 @@ export function declarationToRnEntries(decl: LcDeclaration, themeVars?: Readonly
|
|
|
370
422
|
return [['fontStyle', decl.value.type]]
|
|
371
423
|
}
|
|
372
424
|
case 'display': {
|
|
373
|
-
|
|
425
|
+
// `displayToEntries` can still emit `contents` (a CSS value RN rejects —
|
|
426
|
+
// only `flex` / `none` are valid). Gate the result on the RN-valid set.
|
|
427
|
+
return displayToEntries(decl.value).filter(([, value]) => typeof value === 'string' && RN_ENUM_VALUES.display.has(value))
|
|
374
428
|
}
|
|
375
429
|
case 'position': {
|
|
376
|
-
|
|
430
|
+
// RN `position` accepts only `absolute` / `relative` / `static`; CSS
|
|
431
|
+
// `fixed` / `sticky` are invalid for RN, so drop them.
|
|
432
|
+
return RN_ENUM_VALUES.position.has(decl.value.type) ? [['position', decl.value.type]] : []
|
|
377
433
|
}
|
|
378
434
|
case 'font-size': {
|
|
379
435
|
const px = fontSizeToPx(decl.value)
|
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import type { Declaration as LcDeclaration } from 'lightningcss'
|
|
2
2
|
import type { RNEntry } from './types'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* `justify-content` keywords RN accepts. CSS adds `stretch` / `normal` /
|
|
6
|
+
* `left` / `right` (and `start`/`end`, which we lower to `flex-start`/`flex-end`
|
|
7
|
+
* BEFORE this gate). RN rejects anything outside this set — drop it.
|
|
8
|
+
*/
|
|
9
|
+
const RN_JUSTIFY_CONTENT_VALUES: ReadonlySet<string> = new Set([
|
|
10
|
+
'flex-start',
|
|
11
|
+
'flex-end',
|
|
12
|
+
'center',
|
|
13
|
+
'space-between',
|
|
14
|
+
'space-around',
|
|
15
|
+
'space-evenly',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `align-content` keywords RN accepts. Differs from `justify-content`: RN
|
|
20
|
+
* allows `stretch` here but rejects `normal`. Drop values outside the set.
|
|
21
|
+
*/
|
|
22
|
+
const RN_ALIGN_CONTENT_VALUES: ReadonlySet<string> = new Set([
|
|
23
|
+
'flex-start',
|
|
24
|
+
'flex-end',
|
|
25
|
+
'center',
|
|
26
|
+
'stretch',
|
|
27
|
+
'space-between',
|
|
28
|
+
'space-around',
|
|
29
|
+
'space-evenly',
|
|
30
|
+
])
|
|
31
|
+
|
|
4
32
|
/**
|
|
5
33
|
* Lower a CSS `overflow` keyword to one RN's `overflow` prop accepts
|
|
6
34
|
* (`'visible' | 'hidden' | 'scroll'`). `auto` → `scroll` (auto means
|
|
@@ -105,12 +133,16 @@ export function dispatchLayoutDeclaration(decl: LcDeclaration): readonly RNEntry
|
|
|
105
133
|
return v === null ? [] : [['alignSelf', v]]
|
|
106
134
|
}
|
|
107
135
|
case 'align-content': {
|
|
136
|
+
// After CSS→RN lowering, gate on RN's valid set — drops `normal`
|
|
137
|
+
// (`content-normal`) which RN's `alignContent` rejects.
|
|
108
138
|
const v = mapAlignKeyword(decl.value)
|
|
109
|
-
return v === null ? [] : [['alignContent', v]]
|
|
139
|
+
return v === null || !RN_ALIGN_CONTENT_VALUES.has(v) ? [] : [['alignContent', v]]
|
|
110
140
|
}
|
|
111
141
|
case 'justify-content': {
|
|
142
|
+
// After CSS→RN lowering, gate on RN's valid set — drops `stretch`
|
|
143
|
+
// (`justify-stretch`) and any other keyword RN's `justifyContent` rejects.
|
|
112
144
|
const v = mapJustifyKeyword(decl.value)
|
|
113
|
-
return v === null ? [] : [['justifyContent', v]]
|
|
145
|
+
return v === null || !RN_JUSTIFY_CONTENT_VALUES.has(v) ? [] : [['justifyContent', v]]
|
|
114
146
|
}
|
|
115
147
|
case 'overflow': {
|
|
116
148
|
// Lightningcss splits CSS `overflow` into `{x, y}` axes; RN only
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
PaddingBlock,
|
|
11
11
|
PaddingInline,
|
|
12
12
|
} from 'lightningcss'
|
|
13
|
-
import { cssColorToString } from './color'
|
|
13
|
+
import { cssColorToString, isCssWideColorKeyword } from './color'
|
|
14
14
|
import { dimensionPercentageToNumber, gapValueToValue, lengthPercentageOrAutoToValue } from './length'
|
|
15
15
|
import type { RNEntry } from './types'
|
|
16
16
|
|
|
@@ -135,13 +135,19 @@ export function expandBorderColor(value: BorderColor): readonly RNEntry[] {
|
|
|
135
135
|
const right = cssColorToString(value.right)
|
|
136
136
|
const bottom = cssColorToString(value.bottom)
|
|
137
137
|
const left = cssColorToString(value.left)
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// CSS-wide cascade keywords (`currentColor`, `inherit`, …) have no RN
|
|
139
|
+
// equivalent — drop any side that resolves to one rather than leak the
|
|
140
|
+
// keyword as a `borderColor`/`border*Color` value RN can't paint.
|
|
141
|
+
const sides: readonly (readonly [string, string])[] = [
|
|
140
142
|
['borderTopColor', top],
|
|
141
143
|
['borderRightColor', right],
|
|
142
144
|
['borderBottomColor', bottom],
|
|
143
145
|
['borderLeftColor', left],
|
|
144
146
|
]
|
|
147
|
+
const paintable = sides.filter(([, color]) => !isCssWideColorKeyword(color))
|
|
148
|
+
if (paintable.length === 0) return []
|
|
149
|
+
if (paintable.length === 4 && top === right && right === bottom && bottom === left) return [['borderColor', top]]
|
|
150
|
+
return paintable
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Token, TokenOrValue } from 'lightningcss'
|
|
2
2
|
import { rgb as culoriRgb } from 'culori'
|
|
3
3
|
import { BARE_NUMBER_REGEX, CALC_MUL_REGEX, CALC_RATIO_REGEX, LENGTH_PX_REGEX, LENGTH_REM_REGEX, REM_TO_PX } from './constants'
|
|
4
|
-
import { cssColorToString } from './color'
|
|
4
|
+
import { cssColorToString, normalizeColorString } from './color'
|
|
5
5
|
import type { RNStyleValue } from './types'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -247,6 +247,15 @@ export function coerceUnparsedValue(text: string): RNStyleValue | null {
|
|
|
247
247
|
if (rem) return Number(rem[1]) * REM_TO_PX
|
|
248
248
|
const colorMix = evaluateColorMixWithTransparent(trimmed)
|
|
249
249
|
if (colorMix !== null) return colorMix
|
|
250
|
+
// Real two-color `color-mix(in <space>, A, B)` (not the `, transparent)`
|
|
251
|
+
// opacity shape) — resolve it to a concrete sRGB color via culori so the
|
|
252
|
+
// raw, RN-unreadable `color-mix(...)` string never reaches the StyleSheet.
|
|
253
|
+
if (trimmed.toLowerCase().startsWith('color-mix(')) {
|
|
254
|
+
// Resolved → concrete color; unresolvable → null (DROP). Either way the
|
|
255
|
+
// raw `color-mix(...)` text must never fall through to the string path
|
|
256
|
+
// below, where RN would receive an unreadable value and render nothing.
|
|
257
|
+
return normalizeColorString(trimmed)
|
|
258
|
+
}
|
|
250
259
|
const fallback = extractVariableFallback(trimmed)
|
|
251
260
|
if (fallback !== null) return coerceUnparsedValue(fallback)
|
|
252
261
|
const calcRatio = CALC_RATIO_REGEX.exec(trimmed)
|
|
@@ -1373,6 +1373,11 @@ function parseRgbaExpression(text: string): { color: string; opacity: number } |
|
|
|
1373
1373
|
if (typeof alphaText === 'string') {
|
|
1374
1374
|
opacity = alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText)
|
|
1375
1375
|
}
|
|
1376
|
+
// CSS Color 4: a `none` (or otherwise non-numeric) alpha parses to NaN here.
|
|
1377
|
+
// Its used value when compositing is 0 (fully transparent) — and crucially
|
|
1378
|
+
// RN throws on a NaN `shadowOpacity`, so collapse any non-finite alpha to 0
|
|
1379
|
+
// before it can reach a numeric style prop.
|
|
1380
|
+
if (!Number.isFinite(opacity)) opacity = 0
|
|
1376
1381
|
const hex = `#${[r!, g!, b!]
|
|
1377
1382
|
.map((n) =>
|
|
1378
1383
|
Math.max(0, Math.min(255, Math.round(Number(n))))
|
|
@@ -6,17 +6,26 @@ import type { RNEntry } from './types'
|
|
|
6
6
|
/** RN-supported `textDecorationStyle` values (`wavy` has no RN equivalent). */
|
|
7
7
|
const RN_DECORATION_STYLES: ReadonlySet<string> = new Set(['solid', 'double', 'dotted', 'dashed'])
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The only `textDecorationLine` keywords React Native renders. CSS `overline`
|
|
11
|
+
* has no RN analog, so any line string containing it (or any other unknown
|
|
12
|
+
* keyword) is dropped rather than leaked as a value RN warns on + ignores.
|
|
13
|
+
*/
|
|
14
|
+
const RN_DECORATION_LINES: ReadonlySet<string> = new Set(['none', 'underline', 'line-through', 'underline line-through'])
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Build the RN `textDecorationLine` entry — string identity for the
|
|
11
|
-
* single-line cases, joined-string for the array shape.
|
|
18
|
+
* single-line cases, joined-string for the array shape. Drops any value
|
|
19
|
+
* outside RN's enum (`overline`, `overline underline`, …) so no invalid
|
|
20
|
+
* keyword reaches the StyleSheet.
|
|
12
21
|
* @param value Typed text-decoration-line.
|
|
13
|
-
* @returns Single-entry list with `textDecorationLine
|
|
22
|
+
* @returns Single-entry list with a valid `textDecorationLine`, or empty.
|
|
14
23
|
*/
|
|
15
24
|
function textDecorationLineToEntries(value: LcDeclaration['value']): readonly RNEntry[] {
|
|
16
|
-
if (value === '
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
return []
|
|
25
|
+
if (typeof value === 'string') return RN_DECORATION_LINES.has(value) ? [['textDecorationLine', value]] : []
|
|
26
|
+
if (!Array.isArray(value)) return []
|
|
27
|
+
const line = value.join(' ')
|
|
28
|
+
return RN_DECORATION_LINES.has(line) ? [['textDecorationLine', line]] : []
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import type { GradientAtomInfo, HapticRequest, KeyframeBlock, SchemedStyle, TailwindParser } from '../parser'
|
|
5
5
|
import type { ThemeTables } from '../types'
|
|
@@ -8,6 +8,12 @@ import { buildSchemeSources, type AtomSerializedCache } from './build-style'
|
|
|
8
8
|
/** Manifest module basename — the file SchemeProvider imports via the resolver. */
|
|
9
9
|
const MANIFEST_BASENAME = 'schemes.js'
|
|
10
10
|
|
|
11
|
+
/** Suffix every per-scheme style file carries on disk. */
|
|
12
|
+
const SCHEME_FILE_SUFFIX = '.style.js'
|
|
13
|
+
|
|
14
|
+
/** Registry key for the always-loaded fallback scheme — never reaped. */
|
|
15
|
+
const COMMON_SCHEME = 'common'
|
|
16
|
+
|
|
11
17
|
/**
|
|
12
18
|
* Atomic file write — stage to a `.tmp.<pid>.<nonce>` sibling, then
|
|
13
19
|
* `rename()` into place. Skips the write entirely when the existing
|
|
@@ -66,7 +72,34 @@ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
|
66
72
|
* @returns Absolute path, e.g. `<cacheDir>/dark.style.js`.
|
|
67
73
|
*/
|
|
68
74
|
function schemeFilePath(cacheDir: string, scheme: string): string {
|
|
69
|
-
return path.join(cacheDir, `${scheme}
|
|
75
|
+
return path.join(cacheDir, `${scheme}${SCHEME_FILE_SUFFIX}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* List scheme names whose `<scheme>.style.js` exists on disk but is NOT in
|
|
80
|
+
* the set the current build emits — orphans left by a removed variant
|
|
81
|
+
* (e.g. user drops `@variant dark`, or a theme swap via git pull). The
|
|
82
|
+
* always-loaded `common` scheme is never an orphan. The manifest
|
|
83
|
+
* (`schemes.js`) doesn't carry the `.style.js` suffix, so it's skipped.
|
|
84
|
+
* @param cacheDir Absolute cache directory.
|
|
85
|
+
* @param liveSchemes Scheme names the current build writes.
|
|
86
|
+
* @returns Orphaned scheme names safe to delete.
|
|
87
|
+
*/
|
|
88
|
+
function findOrphanedSchemes(cacheDir: string, liveSchemes: ReadonlySet<string>): readonly string[] {
|
|
89
|
+
let names: readonly string[]
|
|
90
|
+
try {
|
|
91
|
+
names = readdirSync(cacheDir)
|
|
92
|
+
} catch {
|
|
93
|
+
return []
|
|
94
|
+
}
|
|
95
|
+
const orphans: string[] = []
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
if (!name.endsWith(SCHEME_FILE_SUFFIX)) continue
|
|
98
|
+
const scheme = name.slice(0, -SCHEME_FILE_SUFFIX.length)
|
|
99
|
+
if (scheme === COMMON_SCHEME || liveSchemes.has(scheme)) continue
|
|
100
|
+
orphans.push(scheme)
|
|
101
|
+
}
|
|
102
|
+
return orphans
|
|
70
103
|
}
|
|
71
104
|
|
|
72
105
|
/**
|
|
@@ -156,6 +189,15 @@ class UnionBuilder {
|
|
|
156
189
|
return this.serializedMissesCount
|
|
157
190
|
}
|
|
158
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Snapshot of the scheme keys currently tracked in `schemeSignatures` —
|
|
194
|
+
* exposed for tests to assert orphan-signature cleanup.
|
|
195
|
+
* @returns Scheme signature keys (includes the `__manifest` sentinel).
|
|
196
|
+
*/
|
|
197
|
+
public schemeSignatureKeys(): readonly string[] {
|
|
198
|
+
return [...this.schemeSignatures.keys()]
|
|
199
|
+
}
|
|
200
|
+
|
|
159
201
|
/**
|
|
160
202
|
* Absolute path of one scheme's style file.
|
|
161
203
|
* @param scheme Registry key.
|
|
@@ -285,7 +327,7 @@ class UnionBuilder {
|
|
|
285
327
|
for (const [scheme, source] of Object.entries(schemeSources)) {
|
|
286
328
|
const signature = signatureOf(source)
|
|
287
329
|
const target = schemeFilePath(this.cacheDir, scheme)
|
|
288
|
-
if (this.
|
|
330
|
+
if (this.canSkipWrite(scheme, signature, target, source)) continue
|
|
289
331
|
if (writeIfChanged(target, source)) changed.push(scheme)
|
|
290
332
|
this.schemeSignatures.set(scheme, signature)
|
|
291
333
|
}
|
|
@@ -297,9 +339,47 @@ class UnionBuilder {
|
|
|
297
339
|
this.schemeSignatures.set('__manifest', manifestSignature)
|
|
298
340
|
}
|
|
299
341
|
|
|
342
|
+
this.reapOrphanedSchemes(new Set(Object.keys(schemeSources)))
|
|
300
343
|
return { changedSchemes: changed }
|
|
301
344
|
}
|
|
302
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Whether the current write for one scheme can be skipped. A skip is
|
|
348
|
+
* safe only when the cached signature matches AND the bytes on disk
|
|
349
|
+
* still equal the expected source — an `existsSync` pass alone would
|
|
350
|
+
* keep a truncated or externally-modified file (corrupt content with a
|
|
351
|
+
* stale-but-matching signature). The byte read happens only on a
|
|
352
|
+
* signature match, so the common no-change path stays cheap.
|
|
353
|
+
* @param scheme Scheme registry key.
|
|
354
|
+
* @param signature Signature of the source about to be written.
|
|
355
|
+
* @param target Absolute path of the scheme file.
|
|
356
|
+
* @param source Expected file content.
|
|
357
|
+
* @returns Whether writing this scheme can be skipped.
|
|
358
|
+
*/
|
|
359
|
+
private canSkipWrite(scheme: string, signature: string, target: string, source: string): boolean {
|
|
360
|
+
if (this.schemeSignatures.get(scheme) !== signature) return false
|
|
361
|
+
try {
|
|
362
|
+
return readFileSync(target, 'utf8') === source
|
|
363
|
+
} catch {
|
|
364
|
+
// Missing or unreadable on disk — must rewrite.
|
|
365
|
+
return false
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Delete `<scheme>.style.js` files left behind by a scheme that's no
|
|
371
|
+
* longer part of the build (removed `@variant`, theme swap), and drop
|
|
372
|
+
* their cached signatures so a later re-introduction rewrites cleanly.
|
|
373
|
+
* The `common` file and the manifest are never touched.
|
|
374
|
+
* @param liveSchemes Scheme names the current build wrote.
|
|
375
|
+
*/
|
|
376
|
+
private reapOrphanedSchemes(liveSchemes: ReadonlySet<string>): void {
|
|
377
|
+
for (const scheme of findOrphanedSchemes(this.cacheDir, liveSchemes)) {
|
|
378
|
+
rmSync(schemeFilePath(this.cacheDir, scheme), { force: true })
|
|
379
|
+
this.schemeSignatures.delete(scheme)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
303
383
|
/**
|
|
304
384
|
* Ensure the manifest + common scheme files exist on disk so Metro's
|
|
305
385
|
* resolver can SHA1 them at boot before the first transform runs.
|