rnwind 0.0.4 → 0.0.5

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 (127) hide show
  1. package/lib/cjs/core/normalize-classname.cjs +25 -0
  2. package/lib/cjs/core/normalize-classname.cjs.map +1 -0
  3. package/lib/cjs/core/normalize-classname.d.ts +10 -0
  4. package/lib/cjs/core/style-builder/build-style.cjs +258 -58
  5. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
  6. package/lib/cjs/core/style-builder/build-style.d.ts +6 -1
  7. package/lib/cjs/core/style-builder/union-builder.cjs +37 -3
  8. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  9. package/lib/cjs/core/style-builder/union-builder.d.ts +21 -1
  10. package/lib/cjs/metro/dts.cjs +7 -16
  11. package/lib/cjs/metro/dts.cjs.map +1 -1
  12. package/lib/cjs/metro/dts.d.ts +2 -4
  13. package/lib/cjs/metro/state.cjs +30 -78
  14. package/lib/cjs/metro/state.cjs.map +1 -1
  15. package/lib/cjs/metro/state.d.ts +8 -25
  16. package/lib/cjs/metro/transformer.cjs +193 -34
  17. package/lib/cjs/metro/transformer.cjs.map +1 -1
  18. package/lib/cjs/metro/with-config.cjs +2 -2
  19. package/lib/cjs/metro/with-config.cjs.map +1 -1
  20. package/lib/cjs/metro/with-config.d.ts +11 -26
  21. package/lib/cjs/metro/wrap-imports.cjs +273 -0
  22. package/lib/cjs/metro/wrap-imports.cjs.map +1 -0
  23. package/lib/cjs/metro/wrap-imports.d.ts +26 -0
  24. package/lib/cjs/runtime/components/rnwind-provider.cjs +0 -17
  25. package/lib/cjs/runtime/components/rnwind-provider.cjs.map +1 -1
  26. package/lib/cjs/runtime/components/rnwind-provider.d.ts +0 -14
  27. package/lib/cjs/runtime/hooks/use-css.cjs +16 -10
  28. package/lib/cjs/runtime/hooks/use-css.cjs.map +1 -1
  29. package/lib/cjs/runtime/hooks/use-css.d.ts +15 -9
  30. package/lib/cjs/runtime/index.cjs +11 -13
  31. package/lib/cjs/runtime/index.cjs.map +1 -1
  32. package/lib/cjs/runtime/index.d.ts +4 -9
  33. package/lib/cjs/runtime/lookup-css.cjs +10 -0
  34. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  35. package/lib/cjs/runtime/lookup-css.d.ts +7 -0
  36. package/lib/cjs/runtime/resolve.cjs +348 -0
  37. package/lib/cjs/runtime/resolve.cjs.map +1 -0
  38. package/lib/cjs/runtime/resolve.d.ts +61 -0
  39. package/lib/cjs/runtime/wrap.cjs +254 -0
  40. package/lib/cjs/runtime/wrap.cjs.map +1 -0
  41. package/lib/cjs/runtime/wrap.d.ts +37 -0
  42. package/lib/cjs/testing/index.cjs +81 -50
  43. package/lib/cjs/testing/index.cjs.map +1 -1
  44. package/lib/esm/core/normalize-classname.d.ts +10 -0
  45. package/lib/esm/core/normalize-classname.mjs +23 -0
  46. package/lib/esm/core/normalize-classname.mjs.map +1 -0
  47. package/lib/esm/core/style-builder/build-style.d.ts +6 -1
  48. package/lib/esm/core/style-builder/build-style.mjs +258 -58
  49. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  50. package/lib/esm/core/style-builder/union-builder.d.ts +21 -1
  51. package/lib/esm/core/style-builder/union-builder.mjs +37 -3
  52. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  53. package/lib/esm/metro/dts.d.ts +2 -4
  54. package/lib/esm/metro/dts.mjs +7 -16
  55. package/lib/esm/metro/dts.mjs.map +1 -1
  56. package/lib/esm/metro/state.d.ts +8 -25
  57. package/lib/esm/metro/state.mjs +30 -76
  58. package/lib/esm/metro/state.mjs.map +1 -1
  59. package/lib/esm/metro/transformer.mjs +194 -35
  60. package/lib/esm/metro/transformer.mjs.map +1 -1
  61. package/lib/esm/metro/with-config.d.ts +11 -26
  62. package/lib/esm/metro/with-config.mjs +2 -2
  63. package/lib/esm/metro/with-config.mjs.map +1 -1
  64. package/lib/esm/metro/wrap-imports.d.ts +26 -0
  65. package/lib/esm/metro/wrap-imports.mjs +250 -0
  66. package/lib/esm/metro/wrap-imports.mjs.map +1 -0
  67. package/lib/esm/runtime/components/rnwind-provider.d.ts +0 -14
  68. package/lib/esm/runtime/components/rnwind-provider.mjs +1 -17
  69. package/lib/esm/runtime/components/rnwind-provider.mjs.map +1 -1
  70. package/lib/esm/runtime/hooks/use-css.d.ts +15 -9
  71. package/lib/esm/runtime/hooks/use-css.mjs +16 -10
  72. package/lib/esm/runtime/hooks/use-css.mjs.map +1 -1
  73. package/lib/esm/runtime/index.d.ts +4 -9
  74. package/lib/esm/runtime/index.mjs +4 -4
  75. package/lib/esm/runtime/index.mjs.map +1 -1
  76. package/lib/esm/runtime/lookup-css.d.ts +7 -0
  77. package/lib/esm/runtime/lookup-css.mjs +10 -1
  78. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  79. package/lib/esm/runtime/resolve.d.ts +61 -0
  80. package/lib/esm/runtime/resolve.mjs +341 -0
  81. package/lib/esm/runtime/resolve.mjs.map +1 -0
  82. package/lib/esm/runtime/wrap.d.ts +37 -0
  83. package/lib/esm/runtime/wrap.mjs +251 -0
  84. package/lib/esm/runtime/wrap.mjs.map +1 -0
  85. package/lib/esm/testing/index.mjs +84 -53
  86. package/lib/esm/testing/index.mjs.map +1 -1
  87. package/package.json +2 -1
  88. package/src/core/normalize-classname.ts +19 -0
  89. package/src/core/style-builder/build-style.ts +286 -55
  90. package/src/core/style-builder/union-builder.ts +36 -3
  91. package/src/metro/dts.ts +7 -19
  92. package/src/metro/state.ts +29 -74
  93. package/src/metro/transformer.ts +190 -34
  94. package/src/metro/with-config.ts +13 -28
  95. package/src/metro/wrap-imports.ts +260 -0
  96. package/src/runtime/components/rnwind-provider.tsx +0 -17
  97. package/src/runtime/hooks/use-css.ts +17 -11
  98. package/src/runtime/index.ts +3 -26
  99. package/src/runtime/lookup-css.ts +10 -0
  100. package/src/runtime/resolve.ts +381 -0
  101. package/src/runtime/wrap.tsx +267 -0
  102. package/src/testing/index.ts +106 -56
  103. package/lib/cjs/core/parser/text-truncate.cjs +0 -78
  104. package/lib/cjs/core/parser/text-truncate.cjs.map +0 -1
  105. package/lib/cjs/metro/transform-ast.cjs +0 -1472
  106. package/lib/cjs/metro/transform-ast.cjs.map +0 -1
  107. package/lib/cjs/metro/transform-ast.d.ts +0 -88
  108. package/lib/cjs/runtime/haptics.cjs +0 -113
  109. package/lib/cjs/runtime/haptics.cjs.map +0 -1
  110. package/lib/cjs/runtime/haptics.d.ts +0 -48
  111. package/lib/cjs/runtime/interactive-box.cjs +0 -35
  112. package/lib/cjs/runtime/interactive-box.cjs.map +0 -1
  113. package/lib/cjs/runtime/interactive-box.d.ts +0 -40
  114. package/lib/esm/core/parser/text-truncate.mjs +0 -75
  115. package/lib/esm/core/parser/text-truncate.mjs.map +0 -1
  116. package/lib/esm/metro/transform-ast.d.ts +0 -88
  117. package/lib/esm/metro/transform-ast.mjs +0 -1451
  118. package/lib/esm/metro/transform-ast.mjs.map +0 -1
  119. package/lib/esm/runtime/haptics.d.ts +0 -48
  120. package/lib/esm/runtime/haptics.mjs +0 -110
  121. package/lib/esm/runtime/haptics.mjs.map +0 -1
  122. package/lib/esm/runtime/interactive-box.d.ts +0 -40
  123. package/lib/esm/runtime/interactive-box.mjs +0 -33
  124. package/lib/esm/runtime/interactive-box.mjs.map +0 -1
  125. package/src/metro/transform-ast.ts +0 -1729
  126. package/src/runtime/haptics.ts +0 -120
  127. package/src/runtime/interactive-box.tsx +0 -57
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'
4
4
  import { UnionBuilder } from '../core/style-builder'
5
5
  import { TailwindParser, type SourceEntry } from '../core/parser'
6
6
  import { resolveThemeCss } from './css-imports'
7
+ import { buildWrapModules } from './wrap-imports'
7
8
 
8
9
  /**
9
10
  * Default oxide Scanner globs — walk every JS/TS source under the
@@ -49,12 +50,8 @@ const CSS_ENTRY_ENV = 'RNWIND_CSS_ENTRY_FILE'
49
50
  const CACHE_DIR_ENV = 'RNWIND_CACHE_DIR'
50
51
  /** Env var carrying `watchFolders` from Metro config (NUL-separated). */
51
52
  const WATCH_FOLDERS_ENV = 'RNWIND_WATCH_FOLDERS'
52
- /** Env var carrying extra className prefixes the Metro config supplied. */
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
+ /** Env var carrying extra modules whose component exports get `wrap()`-ed. Comma-separated. */
54
+ const WRAP_MODULES_ENV = 'RNWIND_WRAP_MODULES'
58
55
 
59
56
  /** Memoised library fingerprint — read once per worker process. */
60
57
  let libraryFingerprint: string | undefined
@@ -86,10 +83,10 @@ function readThemeHashFor(cssPath: string): string {
86
83
  * dev OR npm install of a new version) the file bytes change, the
87
84
  * fingerprint rotates, and Metro's transform cache invalidates.
88
85
  *
89
- * Includes the JSX rewriter (`transform-ast`) alongside the parser /
90
- * style-builder so a change to the transformer — e.g. renaming the
91
- * injected context hook — invalidates every stale per-file cache entry
92
- * on the next dev run. Without this, a user upgrading rnwind in-place
86
+ * Includes the import-rewriter (`wrap-imports`) and runtime resolver
87
+ * alongside the parser / style-builder so a change to the transformer —
88
+ * e.g. renaming the injected wrap helper — invalidates every stale
89
+ * per-file cache entry on the next dev run. Without this, a user upgrading rnwind in-place
93
90
  * would keep loading the old transformed bytes; React-refresh would
94
91
  * then preserve fiber state across the version bump and the rendered
95
92
  * hook list could shift, surfacing as "change in the order of Hooks"
@@ -105,14 +102,14 @@ function getLibraryFingerprint(): string {
105
102
  path.resolve(here, '..', 'core', 'style-builder', 'build-style.cjs'),
106
103
  path.resolve(here, '..', 'core', 'parser', 'tw-parser.mjs'),
107
104
  path.resolve(here, '..', 'core', 'parser', 'tw-parser.cjs'),
108
- path.resolve(here, 'transform-ast.mjs'),
109
- path.resolve(here, 'transform-ast.cjs'),
105
+ path.resolve(here, 'wrap-imports.mjs'),
106
+ path.resolve(here, 'wrap-imports.cjs'),
110
107
  path.resolve(here, 'transformer.mjs'),
111
108
  path.resolve(here, 'transformer.cjs'),
112
109
  // Source-tree fallback for tests + workspace dev (no built lib yet).
113
110
  path.resolve(here, '..', '..', 'src', 'core', 'style-builder', 'build-style.ts'),
114
111
  path.resolve(here, '..', '..', 'src', 'core', 'parser', 'tw-parser.ts'),
115
- path.resolve(here, '..', '..', 'src', 'metro', 'transform-ast.ts'),
112
+ path.resolve(here, '..', '..', 'src', 'metro', 'wrap-imports.ts'),
116
113
  path.resolve(here, '..', '..', 'src', 'metro', 'transformer.ts'),
117
114
  ]
118
115
  const hash = createHash('sha256')
@@ -148,18 +145,14 @@ export interface RnwindState {
148
145
  * can rebuild the same state without re-reading the Metro config.
149
146
  * @param cssEntryFile Absolute path to the user's theme CSS.
150
147
  * @param cacheDir Absolute path to the cache dir (`.rnwind`).
151
- * @param watchFolders
152
- * @param classNamePrefixes Extra JSX prop-name prefixes to rewrite.
153
- * @param hostSources
154
- * @param hostComponents
148
+ * @param watchFolders Monorepo watch folders to scan for atoms.
149
+ * @param wrapModules Extra modules whose component exports get `wrap()`-ed.
155
150
  */
156
151
  export function configureRnwindState(
157
152
  cssEntryFile: string,
158
153
  cacheDir: string,
159
154
  watchFolders: readonly string[] = [],
160
- classNamePrefixes?: readonly string[],
161
- hostSources?: readonly string[],
162
- hostComponents?: readonly string[],
155
+ wrapModules?: readonly string[],
163
156
  ): void {
164
157
  process.env[CSS_ENTRY_ENV] = cssEntryFile
165
158
  process.env[CACHE_DIR_ENV] = cacheDir
@@ -168,59 +161,23 @@ export function configureRnwindState(
168
161
  } else {
169
162
  process.env[WATCH_FOLDERS_ENV] = watchFolders.join('\0')
170
163
  }
171
- if (!classNamePrefixes || classNamePrefixes.length === 0) {
172
- delete process.env[CLASSNAME_PREFIXES_ENV]
164
+ if (!wrapModules || wrapModules.length === 0) {
165
+ delete process.env[WRAP_MODULES_ENV]
173
166
  } else {
174
- process.env[CLASSNAME_PREFIXES_ENV] = classNamePrefixes.join(',')
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(',')
167
+ process.env[WRAP_MODULES_ENV] = wrapModules.join(',')
185
168
  }
186
169
  cached = null
187
170
  }
188
171
 
189
172
  /**
190
- * Read the caller-configured extra className prefixes out of the
191
- * worker environment. Returns an empty array when unset — the
192
- * transformer applies the built-in `contentContainer` default on top
193
- * either way.
194
- * @returns User-supplied extra prefixes.
173
+ * Effective module → wrap-policy map: the built-in defaults merged with
174
+ * any extra modules the Metro config supplied.
175
+ * @returns Module policy map the import-rewrite consults.
195
176
  */
196
- export function getClassNamePrefixes(): readonly string[] {
197
- const raw = process.env[CLASSNAME_PREFIXES_ENV]
198
- if (!raw || raw.length === 0) return []
199
- return raw.split(',').filter((entry) => entry.length > 0)
200
- }
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)
177
+ export function getWrapModules(): ReturnType<typeof buildWrapModules> {
178
+ const raw = process.env[WRAP_MODULES_ENV]
179
+ const extra = raw && raw.length > 0 ? raw.split(',').filter((entry) => entry.length > 0) : undefined
180
+ return buildWrapModules(extra)
224
181
  }
225
182
 
226
183
  /**
@@ -261,14 +218,12 @@ export function getRnwindState(projectRoot: string): RnwindState {
261
218
  */
262
219
  export function getRnwindCacheKey(): string {
263
220
  const cssEntry = process.env[CSS_ENTRY_ENV] ?? ''
264
- const prefixes = process.env[CLASSNAME_PREFIXES_ENV] ?? ''
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}`
221
+ // Wrap-module config changes which import sites get `wrap()`-ed, so it
222
+ // MUST flip the cache key otherwise Metro replays stale transforms
223
+ // (a newly-opted-in module keeps its raw import, a removed one keeps
224
+ // the wrap).
225
+ const wrapModules = process.env[WRAP_MODULES_ENV] ?? ''
226
+ return `rnwind:${cssEntry}:${readThemeHashFor(cssEntry)}|lib:${getLibraryFingerprint()}|wm:${wrapModules}`
272
227
  }
273
228
 
274
229
  /** Drop the cached state — call after editing the theme CSS. */
@@ -4,8 +4,8 @@ import { parse } from '@babel/parser'
4
4
  import generate from '@babel/generator'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { realpathSync } from 'node:fs'
7
- import { transformAst } from './transform-ast'
8
- import { getClassNamePrefixes, getHostComponents, getHostSources, getRnwindCacheKey, getRnwindState, onThemeChange } from './state'
7
+ import { getRnwindCacheKey, getRnwindState, getWrapModules, onThemeChange } from './state'
8
+ import { rewriteWrapImports } from './wrap-imports'
9
9
  import { STYLE_SPECIFIERS, THEME_SIGNATURE_MODULE } from './resolver'
10
10
  import { filterUnknownClassCandidates } from './warn-unknown-classes'
11
11
 
@@ -62,15 +62,23 @@ function parseUserSource(source: string): File | null {
62
62
  * @param candidates Every candidate oxide surfaced from the source.
63
63
  * @param atoms Successfully resolved atoms (keys are class names).
64
64
  * @param filename Source path, prefixed onto the warning.
65
+ * @param features Feature-atom maps (gradient / haptic) — their names are
66
+ * known classes even though they carry no RN style, so they're excluded
67
+ * from the unknown-class warning.
65
68
  */
66
69
  function warnUnknownClasses(
67
70
  source: string,
68
71
  candidates: readonly string[],
69
72
  atoms: ReadonlyMap<string, unknown>,
70
73
  filename: string,
74
+ features: ReadonlyArray<ReadonlyMap<string, unknown>> = [],
71
75
  ): void {
72
- const atomNames = new Set(atoms.keys())
73
- const unknown = filterUnknownClassCandidates(source, candidates, atomNames)
76
+ // Feature atoms (gradient / haptic) resolve to no RN style, so they're
77
+ // absent from `atoms` — but they're NOT unknown. Fold their names into
78
+ // the known set so `active:haptic-rigid` etc. don't warn at build time.
79
+ const known = new Set(atoms.keys())
80
+ for (const map of features) for (const name of map.keys()) known.add(name)
81
+ const unknown = filterUnknownClassCandidates(source, candidates, known)
74
82
  if (unknown.length === 0) return
75
83
  // eslint-disable-next-line no-console
76
84
  console.warn(`rnwind: unknown class${unknown.length > 1 ? 'es' : ''} in ${filename}: ${unknown.join(', ')}`)
@@ -114,56 +122,183 @@ function isThemeCssEntry(filename: string): boolean {
114
122
  }
115
123
 
116
124
  /**
117
- * Parse + run rnwind's JSX rewrite + regenerate source code. When
118
- * parsing or transformation fails, fall back to the original source —
119
- * we don't want a transient parse error to crash Metro for a file the
120
- * upstream might handle fine.
125
+ * Wrap host imports + compile any className literals, then regenerate
126
+ * source. Two paths:
127
+ * - **className present**: oxide-scan the file, record its atoms into
128
+ * the union, and inject the generated-style + theme-signature
129
+ * side-effect imports so the runtime registries populate.
130
+ * - **import-only** (a `{...rest}` forwarder or a leaf with no literal
131
+ * `className=`): just wrap the host imports so a forwarded className
132
+ * still resolves at render — no oxide scan, no injected imports.
133
+ *
134
+ * On parse failure, fall back to the original source — a transient parse
135
+ * error shouldn't crash Metro for a file the upstream might handle fine.
121
136
  * @param args Metro args; `src` is the original source text.
122
- * @returns Rewritten source text (with `className=` rewrites applied).
137
+ * @returns Rewritten source text.
123
138
  */
124
139
  async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
125
140
  const ast = parseUserSource(args.src)
126
141
  if (!ast) return args.src
127
142
 
143
+ // Wrap host component imports so `<View className=…>` resolves at render
144
+ // through the runtime `wrap` (works for literal, spread, and forwarded
145
+ // classNames alike). No JSX is rewritten here.
146
+ const wrapped = rewriteWrapImports(ast, getWrapModules())
147
+
148
+ if (!/classname=/i.test(args.src)) {
149
+ // Import-only file: nothing to compile. Drop any stale atom
150
+ // contribution (className may have just been removed) and emit the
151
+ // wrapped imports — or the untouched source when nothing wrapped.
152
+ dropFileSafely(args.filename, projectRootOf(args))
153
+ return wrapped ? generateModule(ast).code : args.src
154
+ }
155
+
128
156
  const state = getRnwindState(projectRootOf(args))
129
157
  const extension = extensionOf(args.filename)
130
158
  const parsed = await state.parser.parseAtoms({ content: args.src, extension })
131
159
 
132
- warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename)
160
+ warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename, [parsed.gradientAtoms, parsed.hapticAtoms])
133
161
 
134
- const classNamePrefixes = getClassNamePrefixes()
135
- const hostSources = getHostSources()
136
- const hostComponents = getHostComponents()
137
162
  if (parsed.atoms.size === 0) {
138
163
  state.builder.dropFile(args.filename)
139
164
  await state.builder.writeSchemes()
140
- transformAst(ast, {
141
- styleSpecifiers: [],
142
- gradientAtoms: parsed.gradientAtoms,
143
- hapticAtoms: parsed.hapticAtoms,
144
- classNamePrefixes,
145
- hostSources,
146
- hostComponents,
147
- })
148
165
  injectThemeSignatureImport(ast)
149
166
  return generateModule(ast).code
150
167
  }
151
168
 
152
- const { changed } = await state.builder.recordFile(args.filename, parsed.atoms, parsed.keyframes)
169
+ const literals = collectClassNameLiterals(ast)
170
+ const { changed } = await state.builder.recordFile(args.filename, parsed.atoms, parsed.keyframes, literals)
153
171
  if (changed) await state.builder.writeSchemes()
154
172
 
155
- transformAst(ast, {
156
- styleSpecifiers: STYLE_SPECIFIERS as unknown as readonly string[],
157
- gradientAtoms: parsed.gradientAtoms,
158
- hapticAtoms: parsed.hapticAtoms,
159
- classNamePrefixes,
160
- hostSources,
161
- hostComponents,
162
- })
173
+ injectSideEffectImports(ast, STYLE_SPECIFIERS)
163
174
  injectThemeSignatureImport(ast)
164
175
  return generateModule(ast).code
165
176
  }
166
177
 
178
+ /**
179
+ * Drop a file's union contribution, swallowing the "state not configured"
180
+ * error unit tests hit when they call the transformer without
181
+ * `configureRnwindState`.
182
+ * @param filename Absolute source path.
183
+ * @param projectRoot Project root for state lookup.
184
+ */
185
+ function dropFileSafely(filename: string, projectRoot: string): void {
186
+ try {
187
+ getRnwindState(projectRoot).builder.dropFile(filename)
188
+ } catch {
189
+ // State not configured (standalone/unit test). Nothing to drop.
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Whether a JSX attribute names a className-style prop (`className` or
195
+ * any `<prefix>ClassName`).
196
+ * @param node JSX attribute node.
197
+ * @returns True when the attribute is a className prop.
198
+ */
199
+ function isClassNameAttribute(node: t.JSXAttribute): boolean {
200
+ if (!t.isJSXIdentifier(node.name)) return false
201
+ const {name} = node.name
202
+ return name === 'className' || name.endsWith('ClassName')
203
+ }
204
+
205
+ /**
206
+ * Pull static string literals out of a className expression. Handles a
207
+ * bare string, a no-substitution template, and the branches of a
208
+ * ternary / `&&` (so `cond ? 'a' : 'b'` and `flag && 'x'` both register
209
+ * their literals). Dynamic interpolations are skipped — they resolve via
210
+ * the runtime atom path.
211
+ * @param expr Expression inside a `className={...}` container.
212
+ * @param out Accumulator for discovered literals.
213
+ */
214
+ function collectLiteralsFromExpression(expr: t.Expression | t.JSXEmptyExpression | null | undefined, out: string[]): void {
215
+ if (!expr) return
216
+ if (t.isStringLiteral(expr)) {
217
+ out.push(expr.value)
218
+ return
219
+ }
220
+ if (t.isTemplateLiteral(expr) && expr.expressions.length === 0 && expr.quasis.length === 1) {
221
+ const cooked = expr.quasis[0]?.value.cooked
222
+ if (typeof cooked === 'string') out.push(cooked)
223
+ return
224
+ }
225
+ if (t.isConditionalExpression(expr)) {
226
+ collectLiteralsFromExpression(expr.consequent, out)
227
+ collectLiteralsFromExpression(expr.alternate, out)
228
+ return
229
+ }
230
+ if (t.isLogicalExpression(expr)) {
231
+ collectLiteralsFromExpression(expr.right as t.Expression, out)
232
+ }
233
+ }
234
+
235
+ /** AST node keys the literal walk skips — position / comment metadata. */
236
+ const SKIP_WALK_KEYS = new Set(['type', 'loc', 'start', 'end', 'range', 'leadingComments', 'trailingComments', 'innerComments'])
237
+
238
+ /**
239
+ * Collect the static literals from one className JSX attribute into the
240
+ * dedup accumulator.
241
+ * @param attribute The (already className-matched) JSX attribute.
242
+ * @param seen Dedup set of literals already collected.
243
+ * @param out Ordered accumulator.
244
+ */
245
+ function collectAttributeLiterals(attribute: t.JSXAttribute, seen: Set<string>, out: string[]): void {
246
+ const { value } = attribute
247
+ const found: string[] = []
248
+ if (t.isStringLiteral(value)) found.push(value.value)
249
+ else if (t.isJSXExpressionContainer(value)) collectLiteralsFromExpression(value.expression, found)
250
+ for (const literal of found) {
251
+ if (seen.has(literal)) continue
252
+ seen.add(literal)
253
+ out.push(literal)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Walk the AST for every `className=` / `<prefix>ClassName=` literal so
259
+ * the builder can pre-merge each into a per-scheme molecule. A generic
260
+ * node walk (no scope build) keeps it cheap; only JSX attribute nodes do
261
+ * any work.
262
+ * @param ast Parsed Babel file.
263
+ * @returns Distinct literal className strings, in first-seen order.
264
+ */
265
+ function collectClassNameLiterals(ast: File): readonly string[] {
266
+ const out: string[] = []
267
+ const seen = new Set<string>()
268
+ const visit = (node: unknown): void => {
269
+ if (!node || typeof node !== 'object') return
270
+ if (Array.isArray(node)) {
271
+ for (const child of node) visit(child)
272
+ return
273
+ }
274
+ const typed = node as { type?: string; [key: string]: unknown }
275
+ if (typeof typed.type !== 'string') return
276
+ if (typed.type === 'JSXAttribute' && isClassNameAttribute(node as t.JSXAttribute)) {
277
+ collectAttributeLiterals(node as t.JSXAttribute, seen, out)
278
+ }
279
+ for (const key in typed) {
280
+ if (SKIP_WALK_KEYS.has(key)) continue
281
+ visit(typed[key])
282
+ }
283
+ }
284
+ visit(ast.program)
285
+ return out
286
+ }
287
+
288
+ /**
289
+ * Prepend side-effect imports (`import '<spec>'`) so the generated
290
+ * per-scheme style + manifest modules load — registering this file's
291
+ * atoms / molecules / features into the runtime registries the wrapper's
292
+ * `resolve` reads.
293
+ * @param ast Babel File AST to mutate in place.
294
+ * @param specifiers Module specifiers to side-effect-import.
295
+ */
296
+ function injectSideEffectImports(ast: File, specifiers: readonly string[]): void {
297
+ for (const specifier of specifiers) {
298
+ ast.program.body.unshift(t.importDeclaration([], t.stringLiteral(specifier)))
299
+ }
300
+ }
301
+
167
302
  /**
168
303
  * Prepend `import 'rnwind/__generated/theme-signature'` to every
169
304
  * rnwind-transformed file. The resolver maps that specifier to the
@@ -252,10 +387,16 @@ function loadUpstream(): UpstreamTransformer | null {
252
387
  */
253
388
  function isRewriteCandidate(args: BabelTransformerArgs): boolean {
254
389
  if (!/\.(?:tsx|ts|jsx|js)$/i.test(args.filename)) return false
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
390
+ // Process the file when it either:
391
+ // - carries a `className=` / `<prefix>ClassName=` literal (case-
392
+ // insensitive `contentContainerClassName=` has a capital C), or
393
+ // - spreads props (`{...rest}`) onto a host from a wrap-module, where a
394
+ // forwarded className must still get its import wrapped (no literal
395
+ // appears in this file). A style-less `<View/>` with neither is left
396
+ // alone so it never pays for an unused wrapper.
397
+ const hasClassName = /classname=/i.test(args.src)
398
+ const isForwarder = /\{\s*\.\.\./.test(args.src) && mentionsWrapModule(args.src)
399
+ if (!hasClassName && !isForwarder) return false
259
400
  if (!args.filename.includes('/node_modules/')) return true
260
401
  // node_modules in path → could be a workspace symlink; resolve it.
261
402
  try {
@@ -266,6 +407,21 @@ function isRewriteCandidate(args: BabelTransformerArgs): boolean {
266
407
  }
267
408
  }
268
409
 
410
+ /**
411
+ * Cheap pre-parse check: does the source import from any configured
412
+ * wrap-module? A quoted specifier match is enough — `rewriteWrapImports`
413
+ * re-verifies precisely on the AST, so a false positive only costs a
414
+ * no-op parse.
415
+ * @param source Source text.
416
+ * @returns True when a wrap-module specifier appears in the source.
417
+ */
418
+ function mentionsWrapModule(source: string): boolean {
419
+ for (const moduleName of getWrapModules().keys()) {
420
+ if (source.includes(`'${moduleName}'`) || source.includes(`"${moduleName}"`)) return true
421
+ }
422
+ return false
423
+ }
424
+
269
425
  /**
270
426
  * Fallback parse when no upstream is configured AND Metro didn't hand
271
427
  * us an AST. Used by unit tests and standalone setups.
@@ -127,35 +127,20 @@ export interface RnwindMetroOptions {
127
127
  /** Cache directory. Absolute, or relative to `projectRoot`. Default: `.rnwind` at project root. */
128
128
  cacheDir?: string
129
129
  /**
130
- * Extra JSX prop-name prefixes that rnwind should rewrite. Each
131
- * prefix `P` turns `<Tag PClassName="…">` into `<Tag
132
- * PStyle={lookupCss()}>`. The built-in `'contentContainer'` prefix
133
- * is always on (covers ScrollView / FlatList / SectionList); user
134
- * entries merge on top.
135
- */
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`,
130
+ * Extra module specifiers whose component exports rnwind should
131
+ * auto-wrap at import sites — `import { View } from 'react-native'`
132
+ * becomes `const View = wrap(_rnw0)` so `<View className="…">` resolves
133
+ * styles at runtime. Merged with the built-in defaults: `react-native`,
142
134
  * `react-native-reanimated`, `react-native-svg`,
143
- * `react-native-gesture-handler`, `expo-linear-gradient`, `expo-image`.
135
+ * `react-native-gesture-handler`, `react-native-safe-area-context`,
136
+ * `expo-linear-gradient`, `expo-image`, and more.
144
137
  *
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.
138
+ * A module NOT in this list keeps its raw imports the importing
139
+ * component receives `className` as a plain prop and can resolve it
140
+ * via `useCss` / `wrap` itself. Use this to opt your design-system /
141
+ * UI primitive packages into the auto-wrap path.
157
142
  */
158
- hostComponents?: readonly string[]
143
+ wrapModules?: readonly string[]
159
144
  }
160
145
 
161
146
  /** Shape we mutate on Metro's config. Loose so we don't pin Metro's internal types. */
@@ -202,7 +187,7 @@ export function withRnwindConfig<C extends MetroConfigLike>(metroConfig: C, opti
202
187
 
203
188
  mkdirSync(cacheDir, { recursive: true })
204
189
  const watchFolders = (metroConfig.watchFolders ?? []).filter((p) => typeof p === 'string' && p.length > 0)
205
- configureRnwindState(cssEntry, cacheDir, watchFolders, options.classNamePrefixes, options.hostSources, options.hostComponents)
190
+ configureRnwindState(cssEntry, cacheDir, watchFolders, options.wrapModules)
206
191
 
207
192
  // Warm the state eagerly (in the Metro master process) so oxide's
208
193
  // Scanner walks every project source (and every monorepo
@@ -238,7 +223,7 @@ export function withRnwindConfig<C extends MetroConfigLike>(metroConfig: C, opti
238
223
  if (options.dtsFile !== false) {
239
224
  const dtsPath = options.dtsFile ?? path.resolve(projectRoot, 'rnwind-types.d.ts')
240
225
  const schemes = discoverSchemes(cssEntry, projectRoot)
241
- writeDtsFile(dtsPath, schemes, options.classNamePrefixes)
226
+ writeDtsFile(dtsPath, schemes)
242
227
  }
243
228
 
244
229
  // Watch the theme CSS. On edit, we rewrite scheme files AND touch