rnwind 0.0.2 → 0.0.4

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.
Files changed (87) hide show
  1. package/lib/cjs/core/parser/color.cjs +53 -24
  2. package/lib/cjs/core/parser/color.cjs.map +1 -1
  3. package/lib/cjs/core/parser/layout-dispatcher.cjs +20 -0
  4. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
  5. package/lib/cjs/core/parser/length.cjs +20 -6
  6. package/lib/cjs/core/parser/length.cjs.map +1 -1
  7. package/lib/cjs/core/parser/length.d.ts +6 -3
  8. package/lib/cjs/core/parser/shorthand.cjs +37 -5
  9. package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
  10. package/lib/cjs/core/parser/shorthand.d.ts +11 -5
  11. package/lib/cjs/core/parser/theme-vars.cjs +53 -0
  12. package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
  13. package/lib/cjs/core/parser/theme-vars.d.ts +21 -0
  14. package/lib/cjs/core/parser/tokens.cjs +183 -1
  15. package/lib/cjs/core/parser/tokens.cjs.map +1 -1
  16. package/lib/cjs/core/parser/tw-parser.cjs +140 -27
  17. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
  18. package/lib/cjs/core/parser/tw-parser.d.ts +21 -5
  19. package/lib/cjs/core/parser/typography-dispatcher.cjs +16 -1
  20. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
  21. package/lib/cjs/core/style-builder/build-style.cjs +73 -26
  22. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
  23. package/lib/cjs/metro/css-imports.cjs +81 -0
  24. package/lib/cjs/metro/css-imports.cjs.map +1 -0
  25. package/lib/cjs/metro/css-imports.d.ts +8 -0
  26. package/lib/cjs/metro/state.cjs +60 -10
  27. package/lib/cjs/metro/state.cjs.map +1 -1
  28. package/lib/cjs/metro/state.d.ts +17 -1
  29. package/lib/cjs/metro/transform-ast.cjs +238 -21
  30. package/lib/cjs/metro/transform-ast.cjs.map +1 -1
  31. package/lib/cjs/metro/transform-ast.d.ts +15 -0
  32. package/lib/cjs/metro/transformer.cjs +29 -2
  33. package/lib/cjs/metro/transformer.cjs.map +1 -1
  34. package/lib/cjs/metro/with-config.cjs +1 -1
  35. package/lib/cjs/metro/with-config.cjs.map +1 -1
  36. package/lib/cjs/metro/with-config.d.ts +22 -0
  37. package/lib/esm/core/parser/color.mjs +53 -24
  38. package/lib/esm/core/parser/color.mjs.map +1 -1
  39. package/lib/esm/core/parser/layout-dispatcher.mjs +20 -0
  40. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
  41. package/lib/esm/core/parser/length.d.ts +6 -3
  42. package/lib/esm/core/parser/length.mjs +20 -6
  43. package/lib/esm/core/parser/length.mjs.map +1 -1
  44. package/lib/esm/core/parser/shorthand.d.ts +11 -5
  45. package/lib/esm/core/parser/shorthand.mjs +37 -5
  46. package/lib/esm/core/parser/shorthand.mjs.map +1 -1
  47. package/lib/esm/core/parser/theme-vars.d.ts +21 -0
  48. package/lib/esm/core/parser/theme-vars.mjs +53 -1
  49. package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
  50. package/lib/esm/core/parser/tokens.mjs +183 -1
  51. package/lib/esm/core/parser/tokens.mjs.map +1 -1
  52. package/lib/esm/core/parser/tw-parser.d.ts +21 -5
  53. package/lib/esm/core/parser/tw-parser.mjs +141 -28
  54. package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
  55. package/lib/esm/core/parser/typography-dispatcher.mjs +16 -1
  56. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
  57. package/lib/esm/core/style-builder/build-style.mjs +73 -26
  58. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  59. package/lib/esm/metro/css-imports.d.ts +8 -0
  60. package/lib/esm/metro/css-imports.mjs +79 -0
  61. package/lib/esm/metro/css-imports.mjs.map +1 -0
  62. package/lib/esm/metro/state.d.ts +17 -1
  63. package/lib/esm/metro/state.mjs +60 -12
  64. package/lib/esm/metro/state.mjs.map +1 -1
  65. package/lib/esm/metro/transform-ast.d.ts +15 -0
  66. package/lib/esm/metro/transform-ast.mjs +238 -21
  67. package/lib/esm/metro/transform-ast.mjs.map +1 -1
  68. package/lib/esm/metro/transformer.mjs +30 -3
  69. package/lib/esm/metro/transformer.mjs.map +1 -1
  70. package/lib/esm/metro/with-config.d.ts +22 -0
  71. package/lib/esm/metro/with-config.mjs +1 -1
  72. package/lib/esm/metro/with-config.mjs.map +1 -1
  73. package/package.json +2 -1
  74. package/src/core/parser/color.ts +52 -18
  75. package/src/core/parser/layout-dispatcher.ts +19 -0
  76. package/src/core/parser/length.ts +20 -6
  77. package/src/core/parser/shorthand.ts +35 -5
  78. package/src/core/parser/theme-vars.ts +53 -0
  79. package/src/core/parser/tokens.ts +171 -1
  80. package/src/core/parser/tw-parser.ts +147 -28
  81. package/src/core/parser/typography-dispatcher.ts +15 -1
  82. package/src/core/style-builder/build-style.ts +84 -26
  83. package/src/metro/css-imports.ts +75 -0
  84. package/src/metro/state.ts +58 -10
  85. package/src/metro/transform-ast.ts +249 -18
  86. package/src/metro/transformer.ts +28 -3
  87. package/src/metro/with-config.ts +23 -1
@@ -1,8 +1,9 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { UnionBuilder } from '../core/style-builder'
5
5
  import { TailwindParser, type SourceEntry } from '../core/parser'
6
+ import { resolveThemeCss } from './css-imports'
6
7
 
7
8
  /**
8
9
  * Default oxide Scanner globs — walk every JS/TS source under the
@@ -50,6 +51,10 @@ const CACHE_DIR_ENV = 'RNWIND_CACHE_DIR'
50
51
  const WATCH_FOLDERS_ENV = 'RNWIND_WATCH_FOLDERS'
51
52
  /** Env var carrying extra className prefixes the Metro config supplied. */
52
53
  const CLASSNAME_PREFIXES_ENV = 'RNWIND_CLASSNAME_PREFIXES'
54
+ /** Env var carrying extra import sources whose JSX exports get className→style rewrites. Comma-separated. */
55
+ const HOST_SOURCES_ENV = 'RNWIND_HOST_SOURCES'
56
+ /** Env var carrying extra JSX tag names (verbatim, may contain `.`) treated as hosts. Comma-separated. */
57
+ const HOST_COMPONENTS_ENV = 'RNWIND_HOST_COMPONENTS'
53
58
 
54
59
  /** Memoised library fingerprint — read once per worker process. */
55
60
  let libraryFingerprint: string | undefined
@@ -58,19 +63,18 @@ let libraryFingerprint: string | undefined
58
63
  let cached: RnwindState | null = null
59
64
 
60
65
  /**
61
- * Cheap content-hash readout. SHA-256 prefix of the CSS bytes plus the
62
- * file's mtime nanoseconds (so identical content with different mtime
63
- * atomic rewrites still picks up the change). Returns `'missing'`
64
- * when the file can't be read so the cache key is still deterministic.
66
+ * Cheap content-hash readout. SHA-256 prefix of the FULLY-RESOLVED theme
67
+ * CSS — `@import`s flattened so an edit to a theme file the entry only
68
+ * re-exports (`@import "@acme/ui/theme.css"`) still rotates the hash and
69
+ * invalidates Metro's cache. Returns `'missing'` when the entry can't be
70
+ * read so the cache key stays deterministic.
65
71
  * @param cssPath Absolute CSS path.
66
72
  * @returns 16-char hex content hash.
67
73
  */
68
74
  function readThemeHashFor(cssPath: string): string {
69
75
  if (!existsSync(cssPath)) return 'missing'
70
76
  try {
71
- const bytes = readFileSync(cssPath)
72
- const mtime = statSync(cssPath).mtimeMs.toString()
73
- return createHash('sha256').update(bytes).update(mtime).digest('hex').slice(0, 16)
77
+ return createHash('sha256').update(resolveThemeCss(cssPath)).digest('hex').slice(0, 16)
74
78
  } catch {
75
79
  return 'missing'
76
80
  }
@@ -146,12 +150,16 @@ export interface RnwindState {
146
150
  * @param cacheDir Absolute path to the cache dir (`.rnwind`).
147
151
  * @param watchFolders
148
152
  * @param classNamePrefixes Extra JSX prop-name prefixes to rewrite.
153
+ * @param hostSources
154
+ * @param hostComponents
149
155
  */
150
156
  export function configureRnwindState(
151
157
  cssEntryFile: string,
152
158
  cacheDir: string,
153
159
  watchFolders: readonly string[] = [],
154
160
  classNamePrefixes?: readonly string[],
161
+ hostSources?: readonly string[],
162
+ hostComponents?: readonly string[],
155
163
  ): void {
156
164
  process.env[CSS_ENTRY_ENV] = cssEntryFile
157
165
  process.env[CACHE_DIR_ENV] = cacheDir
@@ -165,6 +173,16 @@ export function configureRnwindState(
165
173
  } else {
166
174
  process.env[CLASSNAME_PREFIXES_ENV] = classNamePrefixes.join(',')
167
175
  }
176
+ if (!hostSources || hostSources.length === 0) {
177
+ delete process.env[HOST_SOURCES_ENV]
178
+ } else {
179
+ process.env[HOST_SOURCES_ENV] = hostSources.join(',')
180
+ }
181
+ if (!hostComponents || hostComponents.length === 0) {
182
+ delete process.env[HOST_COMPONENTS_ENV]
183
+ } else {
184
+ process.env[HOST_COMPONENTS_ENV] = hostComponents.join(',')
185
+ }
168
186
  cached = null
169
187
  }
170
188
 
@@ -181,6 +199,30 @@ export function getClassNamePrefixes(): readonly string[] {
181
199
  return raw.split(',').filter((entry) => entry.length > 0)
182
200
  }
183
201
 
202
+ /**
203
+ * Read the caller-configured extra host module sources out of the
204
+ * worker environment. Empty array when unset — the transformer applies
205
+ * its built-in default list on top either way.
206
+ * @returns User-supplied extra host sources.
207
+ */
208
+ export function getHostSources(): readonly string[] {
209
+ const raw = process.env[HOST_SOURCES_ENV]
210
+ if (!raw || raw.length === 0) return []
211
+ return raw.split(',').filter((entry) => entry.length > 0)
212
+ }
213
+
214
+ /**
215
+ * Read the caller-configured extra host JSX tag names out of the worker
216
+ * environment. Verbatim names — may include `.` for member expressions
217
+ * like `'Animated.View'`.
218
+ * @returns User-supplied extra host component names.
219
+ */
220
+ export function getHostComponents(): readonly string[] {
221
+ const raw = process.env[HOST_COMPONENTS_ENV]
222
+ if (!raw || raw.length === 0) return []
223
+ return raw.split(',').filter((entry) => entry.length > 0)
224
+ }
225
+
184
226
  /**
185
227
  * Fetch (or build) the worker-local rnwind state. Re-reads the theme
186
228
  * CSS hash on every call: if the user edited `global.css` while Metro
@@ -198,7 +240,7 @@ export function getRnwindState(projectRoot: string): RnwindState {
198
240
  if (!cacheDir) throw new Error('rnwind: RNWIND_CACHE_DIR is not set — did `withRnwindConfig` run?')
199
241
  const currentHash = readThemeHashFor(cssEntry)
200
242
  if (cached?.themeHash === currentHash && cached.projectRoot === projectRoot) return cached
201
- const themeCss = readFileSync(cssEntry, 'utf8')
243
+ const themeCss = resolveThemeCss(cssEntry)
202
244
  const parser = new TailwindParser({
203
245
  themeCss,
204
246
  sources: defaultSources(projectRoot, cacheDir, readWatchFolders()),
@@ -220,7 +262,13 @@ export function getRnwindState(projectRoot: string): RnwindState {
220
262
  export function getRnwindCacheKey(): string {
221
263
  const cssEntry = process.env[CSS_ENTRY_ENV] ?? ''
222
264
  const prefixes = process.env[CLASSNAME_PREFIXES_ENV] ?? ''
223
- return `rnwind:${cssEntry}:${readThemeHashFor(cssEntry)}|lib:${getLibraryFingerprint()}|pfx:${prefixes}`
265
+ // Host source / component config changes which JSX tags get rewritten,
266
+ // so it MUST flip the cache key — otherwise Metro replays stale
267
+ // transforms (a newly-opted-in host keeps its raw className, a removed
268
+ // one keeps the rewrite).
269
+ const hostSources = process.env[HOST_SOURCES_ENV] ?? ''
270
+ const hostComponents = process.env[HOST_COMPONENTS_ENV] ?? ''
271
+ return `rnwind:${cssEntry}:${readThemeHashFor(cssEntry)}|lib:${getLibraryFingerprint()}|pfx:${prefixes}|hs:${hostSources}|hc:${hostComponents}`
224
272
  }
225
273
 
226
274
  /** Drop the cached state — call after editing the theme CSS. */
@@ -135,6 +135,21 @@ export interface TransformAstOptions {
135
135
  * or dynamic.
136
136
  */
137
137
  classNamePrefixes?: readonly string[]
138
+ /**
139
+ * Extra module specifiers whose JSX exports the transformer should
140
+ * treat as hosts (rewrite `className` → `style` at compile time).
141
+ * Merged with the built-in {@link DEFAULT_HOST_SOURCES} list. Use
142
+ * this for design-system packages whose primitives wrap RN hosts and
143
+ * accept `style` directly.
144
+ */
145
+ hostSources?: readonly string[]
146
+ /**
147
+ * Extra component names (verbatim, including dotted member access
148
+ * like `'Animated.View'`) the transformer should treat as hosts. Use
149
+ * this for one-off escape-hatches that aren't matchable by source —
150
+ * e.g. you alias `View as MyBox` and want the compile-time path.
151
+ */
152
+ hostComponents?: readonly string[]
138
153
  }
139
154
 
140
155
  /**
@@ -145,6 +160,162 @@ export interface TransformAstOptions {
145
160
  */
146
161
  const DEFAULT_CLASSNAME_PREFIXES: readonly string[] = ['contentContainer']
147
162
 
163
+ /**
164
+ * Module specifiers whose JSX exports are "host-like" — they consume
165
+ * `style` directly (and own no opaque component logic that depends on
166
+ * receiving the raw `className` string). For tags imported from these
167
+ * sources the transformer rewrites `className="…"` → `style={lookupCss(…)}`
168
+ * at build time, so the runtime cost is zero.
169
+ *
170
+ * For tags from ANY other source the transformer leaves `className`
171
+ * alone — the importing component receives the raw string and decides
172
+ * what to do with it (forward to an inner host, reshape, route a slice
173
+ * to `contentContainerStyle`, …). This is what makes patterns like
174
+ * `<MyButton className="px-4 bg-primary" />` work without rnwind
175
+ * stealing the prop before the component sees it.
176
+ *
177
+ * Users extend the list via `withRnwindConfig`'s `hostSources` option.
178
+ */
179
+ const DEFAULT_HOST_SOURCES: readonly string[] = [
180
+ 'react-native',
181
+ 'react-native-reanimated',
182
+ 'react-native-svg',
183
+ 'react-native-gesture-handler',
184
+ 'react-native-safe-area-context',
185
+ 'expo-linear-gradient',
186
+ 'expo-image',
187
+ 'expo-blur',
188
+ 'expo-symbols',
189
+ '@shopify/flash-list',
190
+ '@shopify/react-native-skia',
191
+ 'lottie-react-native',
192
+ ]
193
+
194
+ /**
195
+ * Whether a JSX tag name is lowercase. Lowercase tags don't appear in
196
+ * native React Native userland — but if one shows up (web target via
197
+ * `react-native-web`, mdx, etc.) treat it as a host so the rewrite
198
+ * engages instead of silently dropping the className.
199
+ * @param name JSX tag identifier text.
200
+ * @returns True for ASCII-lowercase first character.
201
+ */
202
+ function isLowercaseTag(name: string): boolean {
203
+ const code = name.codePointAt(0)
204
+ return code !== undefined && code >= 97 && code <= 122
205
+ }
206
+
207
+ /**
208
+ * Walk a JSX opening element's tag name node into a dotted string
209
+ * (`Animated.View`, `Foo.Bar.Baz`). Returns `null` for namespaced names
210
+ * (`<svg:rect>` — invalid in RN; we skip them).
211
+ * @param name JSXOpeningElement name node.
212
+ * @returns Dotted tag text, or null.
213
+ */
214
+ function jsxTagText(name: t.JSXOpeningElement['name']): string | null {
215
+ if (t.isJSXIdentifier(name)) return name.name
216
+ if (t.isJSXMemberExpression(name)) {
217
+ const left = jsxTagText(name.object as t.JSXOpeningElement['name'])
218
+ return left ? `${left}.${name.property.name}` : null
219
+ }
220
+ return null
221
+ }
222
+
223
+ /**
224
+ * Leftmost identifier of a (possibly dotted) tag — used to look up its import source.
225
+ * @param tagText
226
+ */
227
+ function leftmostIdentifier(tagText: string): string {
228
+ const dot = tagText.indexOf('.')
229
+ return dot === -1 ? tagText : tagText.slice(0, dot)
230
+ }
231
+
232
+ /** Resolves a tag-text to "is this a host?" using import sources + user-extended host names. */
233
+ type HostLookup = (tagText: string) => boolean
234
+
235
+ /**
236
+ * Build the per-file host lookup. Walks every `import` declaration once
237
+ * to map every locally-bound name to its source module. A JSX tag is a
238
+ * host when:
239
+ * 1. its full text matches an entry in `extraHostComponents` (verbatim),
240
+ * 2. its leftmost identifier was imported from a `hostSources` module,
241
+ * 3. it's a lowercase tag (web targets, defensive).
242
+ *
243
+ * Anything else is custom and the transformer leaves its className alone.
244
+ * @param ast File AST.
245
+ * @param extraHostSources User-supplied additional host module specifiers.
246
+ * @param extraHostComponents User-supplied additional host component names.
247
+ * @returns Lookup callback.
248
+ */
249
+ function buildHostLookup(
250
+ ast: File,
251
+ extraHostSources: readonly string[] | undefined,
252
+ extraHostComponents: readonly string[] | undefined,
253
+ ): HostLookup {
254
+ const importSourceByLocal = new Map<string, string>()
255
+ for (const node of ast.program.body) {
256
+ if (!t.isImportDeclaration(node)) continue
257
+ const source = node.source.value
258
+ for (const spec of node.specifiers) {
259
+ if (t.isImportDefaultSpecifier(spec) || t.isImportSpecifier(spec) || t.isImportNamespaceSpecifier(spec)) {
260
+ importSourceByLocal.set(spec.local.name, source)
261
+ }
262
+ }
263
+ }
264
+ // Recognise module-local host aliases — common pattern in React Native:
265
+ // const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
266
+ // const Animated = createAnimatedComponent(View)
267
+ // The local binding wraps a host underneath so its className must still
268
+ // be rewritten. Without this every `<AnimatedTextInput className="…" />`
269
+ // site looked custom and the className silently dropped.
270
+ const localHostAliases = collectCreateAnimatedComponentAliases(ast)
271
+ const hostSources = new Set<string>([...DEFAULT_HOST_SOURCES, ...(extraHostSources ?? [])])
272
+ const hostComponents = new Set<string>(extraHostComponents)
273
+ return (tagText: string): boolean => {
274
+ if (isLowercaseTag(tagText)) return true
275
+ if (hostComponents.has(tagText)) return true
276
+ const left = leftmostIdentifier(tagText)
277
+ if (localHostAliases.has(left)) return true
278
+ const source = importSourceByLocal.get(left)
279
+ return source !== undefined && hostSources.has(source)
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Walk top-level `const X = createAnimatedComponent(Y)` /
285
+ * `Animated.createAnimatedComponent(Y)` declarations and return the set
286
+ * of local names so the host-lookup recognises them. Reanimated +
287
+ * RN-core `Animated.createAnimatedComponent` are the only creators in
288
+ * common use; matching by callee-name covers both shapes without
289
+ * needing import-source resolution.
290
+ * @param ast File AST.
291
+ * @returns Set of locally-bound names that wrap a host component.
292
+ */
293
+ function collectCreateAnimatedComponentAliases(ast: File): ReadonlySet<string> {
294
+ const aliases = new Set<string>()
295
+ for (const node of ast.program.body) {
296
+ const declaration = t.isExportNamedDeclaration(node) ? node.declaration : node
297
+ if (!t.isVariableDeclaration(declaration)) continue
298
+ for (const decl of declaration.declarations) {
299
+ if (!t.isIdentifier(decl.id) || !decl.init) continue
300
+ if (!isCreateAnimatedComponentCall(decl.init)) continue
301
+ aliases.add(decl.id.name)
302
+ }
303
+ }
304
+ return aliases
305
+ }
306
+
307
+ /**
308
+ * True for `createAnimatedComponent(...)` and `<x>.createAnimatedComponent(...)` calls.
309
+ * @param expr
310
+ */
311
+ function isCreateAnimatedComponentCall(expr: t.Expression): boolean {
312
+ if (!t.isCallExpression(expr)) return false
313
+ const { callee } = expr
314
+ if (t.isIdentifier(callee) && callee.name === 'createAnimatedComponent') return true
315
+ if (t.isMemberExpression(callee) && t.isIdentifier(callee.property) && callee.property.name === 'createAnimatedComponent') return true
316
+ return false
317
+ }
318
+
148
319
  /**
149
320
  * Mutate an already-parsed Babel AST in place:
150
321
  * - Rewrite every JSX `className="…"` / `className={expr}` attribute to
@@ -168,6 +339,7 @@ export function transformAst(ast: File, options: TransformAstOptions): Transform
168
339
  const literals: string[] = []
169
340
  const prefixSet = buildPrefixSet(options.classNamePrefixes)
170
341
  const hapticHoister = createHapticHoister()
342
+ const isHostTag = buildHostLookup(ast, options.hostSources, options.hostComponents)
171
343
  const rewriteCtx: RewriteContext = {
172
344
  needsInsets: false,
173
345
  gradientAtoms: options.gradientAtoms ?? EMPTY_GRADIENT_ATOMS,
@@ -180,6 +352,15 @@ export function transformAst(ast: File, options: TransformAstOptions): Transform
180
352
  let touched = false
181
353
  let usedLookupCss = false
182
354
  let usedInteractiveBox = false
355
+ // Per-element host classification, captured the first time we see each
356
+ // JSXOpeningElement. Necessary because the InteractiveBox wrap mutates
357
+ // `parent.name` in-place from the original tag → `_ib`; sibling
358
+ // attributes processed AFTER the swap would otherwise re-classify off
359
+ // the now-meaningless `_ib` name and skip rewrites they should do
360
+ // (e.g. `contentContainerClassName` next to an `active:` className on
361
+ // the same `<ScrollView>`).
362
+ const customElements = new WeakSet<t.JSXOpeningElement>()
363
+ const classifiedElements = new WeakSet<t.JSXOpeningElement>()
183
364
 
184
365
  traverse(ast, {
185
366
  JSXAttribute(attributePath: NodePath<t.JSXAttribute>) {
@@ -187,6 +368,22 @@ export function transformAst(ast: File, options: TransformAstOptions): Transform
187
368
  if (!t.isJSXIdentifier(node.name)) return
188
369
  const target = classifyAttributeName(node.name.name, prefixSet)
189
370
  if (!target) return
371
+ // Skip className rewrite when the parent JSX tag is a custom
372
+ // component (not imported from a known host source). Custom
373
+ // components own their `className` prop — the transformer would
374
+ // steal the string from under them otherwise. The literal still
375
+ // appears in source text, so oxide still discovers its atoms via
376
+ // the project scan; the inner host that ultimately consumes the
377
+ // forwarded className gets rewritten by ITS file's transform.
378
+ const { parent } = attributePath
379
+ if (t.isJSXOpeningElement(parent)) {
380
+ if (!classifiedElements.has(parent)) {
381
+ classifiedElements.add(parent)
382
+ const tagText = jsxTagText(parent.name)
383
+ if (tagText !== null && !isHostTag(tagText)) customElements.add(parent)
384
+ }
385
+ if (customElements.has(parent)) return
386
+ }
190
387
  const rewritten = rewriteClassNameAttribute(attributePath, hoister, literals, rewriteCtx, target)
191
388
  if (!rewritten) return
192
389
  touched = true
@@ -334,10 +531,17 @@ function rewriteClassNameAttribute(
334
531
  const { node } = attributePath
335
532
  const { value } = node
336
533
  if (!value) return null
337
- const buildResult = buildFirstArgument(value, hoister, literals, rewriteCtx)
338
- if (!buildResult) return null
339
534
  const { parent } = attributePath
340
535
  if (!t.isJSXOpeningElement(parent)) return null
536
+ // The rewrite emits references to `_t` (the `useR_()` binding). That
537
+ // binding can only live in a component body — so if this JSX site has
538
+ // no enclosing component (e.g. a top-level `const renderItem = (...) =>
539
+ // <View className=.../>` helper), bail and leave className untouched
540
+ // rather than emit a dangling `_t`. Checked BEFORE any mutation
541
+ // (hoist, sibling-style drop) so a bail leaves the AST pristine.
542
+ if (!hasComponentBody(attributePath)) return null
543
+ const buildResult = buildFirstArgument(value, hoister, literals, rewriteCtx)
544
+ if (!buildResult) return null
341
545
  const userStyleExpr = extractAndDropSiblingStyle(parent, target.styleProp)
342
546
  // Single context binding `_t = _r()` — carries scheme, fontScale,
343
547
  // insets together so React tracks all three as render deps via one
@@ -388,25 +592,32 @@ function applyDerivedJsxAttributes(
388
592
  }
389
593
 
390
594
  /**
391
- * Splice gradient JSX attributes (`colors={…}` / `start={…}` /
392
- * `end={…}`) into a JSXOpeningElement's attribute list, replacing
393
- * any already-present attribute with the same name. Users who manually
394
- * set `colors=` on the same element lose; rnwind's class-derived
395
- * values win — matching how `className`-resolved styles override
396
- * inline `style={}`.
595
+ * Splice class-derived JSX attributes (`colors={…}` / `start={…}` /
596
+ * `end={…}` for gradients; `numberOfLines=` / `ellipsizeMode=` for
597
+ * truncate) into a JSXOpeningElement's attribute list but only when
598
+ * the developer hasn't already written that attribute themselves.
599
+ *
600
+ * **User attrs always win.** If a hand-written `colors={USER}` is
601
+ * present, the class-derived hoist is dropped on the floor for that
602
+ * specific attribute. Same rule for every derived prop, applied
603
+ * per-attribute so the user can override one slot (e.g. `start={…}`)
604
+ * and let rnwind fill in the others. Documented in
605
+ * `docs/architecture.md`.
397
606
  * @param opening JSXOpeningElement to mutate.
398
- * @param gradientAttrs Freshly built JSX attributes.
399
- * @param gradientAttributes
607
+ * @param gradientAttributes Freshly built JSX attributes.
400
608
  */
401
609
  function appendGradientAttributes(opening: t.JSXOpeningElement, gradientAttributes: readonly t.JSXAttribute[]): void {
402
- const names = new Set<string>()
403
- for (const attribute of gradientAttributes) if (t.isJSXIdentifier(attribute.name)) names.add(attribute.name.name)
404
- opening.attributes = opening.attributes.filter((attribute) => {
405
- if (!t.isJSXAttribute(attribute)) return true
406
- if (!t.isJSXIdentifier(attribute.name)) return true
407
- return !names.has(attribute.name.name)
408
- })
409
- opening.attributes.push(...gradientAttributes)
610
+ const userAttributeNames = new Set<string>()
611
+ for (const attribute of opening.attributes) {
612
+ if (!t.isJSXAttribute(attribute)) continue
613
+ if (!t.isJSXIdentifier(attribute.name)) continue
614
+ userAttributeNames.add(attribute.name.name)
615
+ }
616
+ for (const derived of gradientAttributes) {
617
+ if (!t.isJSXIdentifier(derived.name)) continue
618
+ if (userAttributeNames.has(derived.name.name)) continue
619
+ opening.attributes.push(derived)
620
+ }
410
621
  }
411
622
 
412
623
  /**
@@ -1183,6 +1394,26 @@ function injectContextHook(path: NodePath): string {
1183
1394
  return CONTEXT_BINDING
1184
1395
  }
1185
1396
 
1397
+ /**
1398
+ * Whether `path` sits inside a recognised function component — i.e.
1399
+ * {@link injectContextHook} would find a body to host `const _t =
1400
+ * useR_()`. Pure lookup that mirrors {@link findComponentBody}'s walk
1401
+ * but performs NO body promotion, so a caller can bail before mutating
1402
+ * when the answer is no.
1403
+ * @param path Rewrite-site path.
1404
+ * @returns True when an enclosing component function exists.
1405
+ */
1406
+ function hasComponentBody(path: NodePath): boolean {
1407
+ let current: NodePath | null = path
1408
+ while (current) {
1409
+ const fn = current.findParent((parent) => parent.isFunction())
1410
+ if (!fn) return false
1411
+ if (isComponentFunction(fn)) return true
1412
+ current = fn
1413
+ }
1414
+ return false
1415
+ }
1416
+
1186
1417
  /**
1187
1418
  * Walk up from `path` to the nearest recognised function component.
1188
1419
  * Accepts:
@@ -3,8 +3,9 @@ import * as t from '@babel/types'
3
3
  import { parse } from '@babel/parser'
4
4
  import generate from '@babel/generator'
5
5
  import { createHash } from 'node:crypto'
6
+ import { realpathSync } from 'node:fs'
6
7
  import { transformAst } from './transform-ast'
7
- import { getClassNamePrefixes, getRnwindCacheKey, getRnwindState, onThemeChange } from './state'
8
+ import { getClassNamePrefixes, getHostComponents, getHostSources, getRnwindCacheKey, getRnwindState, onThemeChange } from './state'
8
9
  import { STYLE_SPECIFIERS, THEME_SIGNATURE_MODULE } from './resolver'
9
10
  import { filterUnknownClassCandidates } from './warn-unknown-classes'
10
11
 
@@ -131,6 +132,8 @@ async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
131
132
  warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename)
132
133
 
133
134
  const classNamePrefixes = getClassNamePrefixes()
135
+ const hostSources = getHostSources()
136
+ const hostComponents = getHostComponents()
134
137
  if (parsed.atoms.size === 0) {
135
138
  state.builder.dropFile(args.filename)
136
139
  await state.builder.writeSchemes()
@@ -139,6 +142,8 @@ async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
139
142
  gradientAtoms: parsed.gradientAtoms,
140
143
  hapticAtoms: parsed.hapticAtoms,
141
144
  classNamePrefixes,
145
+ hostSources,
146
+ hostComponents,
142
147
  })
143
148
  injectThemeSignatureImport(ast)
144
149
  return generateModule(ast).code
@@ -152,6 +157,8 @@ async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
152
157
  gradientAtoms: parsed.gradientAtoms,
153
158
  hapticAtoms: parsed.hapticAtoms,
154
159
  classNamePrefixes,
160
+ hostSources,
161
+ hostComponents,
155
162
  })
156
163
  injectThemeSignatureImport(ast)
157
164
  return generateModule(ast).code
@@ -232,13 +239,31 @@ function loadUpstream(): UpstreamTransformer | null {
232
239
  /**
233
240
  * Cheap guard — the file has to look JS/TS, live outside `node_modules`,
234
241
  * and mention `className=` before we spend AST cycles on it.
242
+ *
243
+ * Symlink awareness: monorepo workspaces (yarn / pnpm / bun workspaces)
244
+ * symlink each package into the consumer's `node_modules/<name>`, so a
245
+ * file from `packages/ui/src/Foo.tsx` ends up reaching the transformer
246
+ * as `<root>/node_modules/ui/src/Foo.tsx`. The naïve `/node_modules/`
247
+ * check would skip every workspace UI file. We `realpath` the filename
248
+ * once and only bail when the resolved real path is ALSO under
249
+ * node_modules — true third-party installs.
235
250
  * @param args Metro args.
236
251
  * @returns Whether the file might need the rnwind pass.
237
252
  */
238
253
  function isRewriteCandidate(args: BabelTransformerArgs): boolean {
239
254
  if (!/\.(?:tsx|ts|jsx|js)$/i.test(args.filename)) return false
240
- if (args.filename.includes('/node_modules/')) return false
241
- return args.src.includes('className=')
255
+ // Case-insensitive so `<prefix>ClassName=` (e.g. `contentContainerClassName=`)
256
+ // — which has a capital `C` and so doesn't contain the lowercase
257
+ // `className=` — still routes the file through the rewrite pass.
258
+ if (!/classname=/i.test(args.src)) return false
259
+ if (!args.filename.includes('/node_modules/')) return true
260
+ // node_modules in path → could be a workspace symlink; resolve it.
261
+ try {
262
+ return !realpathSync(args.filename).includes('/node_modules/')
263
+ } catch {
264
+ // realpath failed (broken symlink, missing file). Fall back to skipping.
265
+ return false
266
+ }
242
267
  }
243
268
 
244
269
  /**
@@ -134,6 +134,28 @@ export interface RnwindMetroOptions {
134
134
  * entries merge on top.
135
135
  */
136
136
  classNamePrefixes?: readonly string[]
137
+ /**
138
+ * Extra module specifiers whose JSX exports rnwind should treat as
139
+ * "host components" — i.e. tags whose `className="…"` attribute is
140
+ * rewritten to `style={lookupCss(…)}` at build time (zero runtime
141
+ * cost). Merged with the built-in defaults: `react-native`,
142
+ * `react-native-reanimated`, `react-native-svg`,
143
+ * `react-native-gesture-handler`, `expo-linear-gradient`, `expo-image`.
144
+ *
145
+ * Anything NOT marked as a host has its `className` left untouched —
146
+ * the importing component receives the raw string and decides what
147
+ * to do with it. Use this option to opt your design-system / UI
148
+ * primitive packages into the zero-runtime path.
149
+ */
150
+ hostSources?: readonly string[]
151
+ /**
152
+ * Extra JSX tag names (verbatim — may include `.` for member access
153
+ * like `'Animated.View'`) rnwind should treat as host components,
154
+ * regardless of where they're imported from. Useful for one-off
155
+ * escape-hatches: `import { View as MyBox } from 'react-native'`
156
+ * doesn't change the local name → `'MyBox'` here picks it up.
157
+ */
158
+ hostComponents?: readonly string[]
137
159
  }
138
160
 
139
161
  /** Shape we mutate on Metro's config. Loose so we don't pin Metro's internal types. */
@@ -180,7 +202,7 @@ export function withRnwindConfig<C extends MetroConfigLike>(metroConfig: C, opti
180
202
 
181
203
  mkdirSync(cacheDir, { recursive: true })
182
204
  const watchFolders = (metroConfig.watchFolders ?? []).filter((p) => typeof p === 'string' && p.length > 0)
183
- configureRnwindState(cssEntry, cacheDir, watchFolders, options.classNamePrefixes)
205
+ configureRnwindState(cssEntry, cacheDir, watchFolders, options.classNamePrefixes, options.hostSources, options.hostComponents)
184
206
 
185
207
  // Warm the state eagerly (in the Metro master process) so oxide's
186
208
  // Scanner walks every project source (and every monorepo