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.
- package/lib/cjs/core/parser/color.cjs +53 -24
- package/lib/cjs/core/parser/color.cjs.map +1 -1
- package/lib/cjs/core/parser/layout-dispatcher.cjs +20 -0
- package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/length.cjs +20 -6
- package/lib/cjs/core/parser/length.cjs.map +1 -1
- package/lib/cjs/core/parser/length.d.ts +6 -3
- package/lib/cjs/core/parser/shorthand.cjs +37 -5
- package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
- package/lib/cjs/core/parser/shorthand.d.ts +11 -5
- package/lib/cjs/core/parser/theme-vars.cjs +53 -0
- package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
- package/lib/cjs/core/parser/theme-vars.d.ts +21 -0
- package/lib/cjs/core/parser/tokens.cjs +183 -1
- package/lib/cjs/core/parser/tokens.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.cjs +140 -27
- package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.d.ts +21 -5
- package/lib/cjs/core/parser/typography-dispatcher.cjs +16 -1
- package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/style-builder/build-style.cjs +73 -26
- package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
- package/lib/cjs/metro/css-imports.cjs +81 -0
- package/lib/cjs/metro/css-imports.cjs.map +1 -0
- package/lib/cjs/metro/css-imports.d.ts +8 -0
- package/lib/cjs/metro/state.cjs +60 -10
- package/lib/cjs/metro/state.cjs.map +1 -1
- package/lib/cjs/metro/state.d.ts +17 -1
- package/lib/cjs/metro/transform-ast.cjs +238 -21
- package/lib/cjs/metro/transform-ast.cjs.map +1 -1
- package/lib/cjs/metro/transform-ast.d.ts +15 -0
- package/lib/cjs/metro/transformer.cjs +29 -2
- package/lib/cjs/metro/transformer.cjs.map +1 -1
- package/lib/cjs/metro/with-config.cjs +1 -1
- package/lib/cjs/metro/with-config.cjs.map +1 -1
- package/lib/cjs/metro/with-config.d.ts +22 -0
- package/lib/esm/core/parser/color.mjs +53 -24
- package/lib/esm/core/parser/color.mjs.map +1 -1
- package/lib/esm/core/parser/layout-dispatcher.mjs +20 -0
- package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/length.d.ts +6 -3
- package/lib/esm/core/parser/length.mjs +20 -6
- package/lib/esm/core/parser/length.mjs.map +1 -1
- package/lib/esm/core/parser/shorthand.d.ts +11 -5
- package/lib/esm/core/parser/shorthand.mjs +37 -5
- package/lib/esm/core/parser/shorthand.mjs.map +1 -1
- package/lib/esm/core/parser/theme-vars.d.ts +21 -0
- package/lib/esm/core/parser/theme-vars.mjs +53 -1
- package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
- package/lib/esm/core/parser/tokens.mjs +183 -1
- package/lib/esm/core/parser/tokens.mjs.map +1 -1
- package/lib/esm/core/parser/tw-parser.d.ts +21 -5
- package/lib/esm/core/parser/tw-parser.mjs +141 -28
- package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs +16 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
- package/lib/esm/core/style-builder/build-style.mjs +73 -26
- package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
- package/lib/esm/metro/css-imports.d.ts +8 -0
- package/lib/esm/metro/css-imports.mjs +79 -0
- package/lib/esm/metro/css-imports.mjs.map +1 -0
- package/lib/esm/metro/state.d.ts +17 -1
- package/lib/esm/metro/state.mjs +60 -12
- package/lib/esm/metro/state.mjs.map +1 -1
- package/lib/esm/metro/transform-ast.d.ts +15 -0
- package/lib/esm/metro/transform-ast.mjs +238 -21
- package/lib/esm/metro/transform-ast.mjs.map +1 -1
- package/lib/esm/metro/transformer.mjs +30 -3
- package/lib/esm/metro/transformer.mjs.map +1 -1
- package/lib/esm/metro/with-config.d.ts +22 -0
- package/lib/esm/metro/with-config.mjs +1 -1
- package/lib/esm/metro/with-config.mjs.map +1 -1
- package/package.json +2 -1
- package/src/core/parser/color.ts +52 -18
- package/src/core/parser/layout-dispatcher.ts +19 -0
- package/src/core/parser/length.ts +20 -6
- package/src/core/parser/shorthand.ts +35 -5
- package/src/core/parser/theme-vars.ts +53 -0
- package/src/core/parser/tokens.ts +171 -1
- package/src/core/parser/tw-parser.ts +147 -28
- package/src/core/parser/typography-dispatcher.ts +15 -1
- package/src/core/style-builder/build-style.ts +84 -26
- package/src/metro/css-imports.ts +75 -0
- package/src/metro/state.ts +58 -10
- package/src/metro/transform-ast.ts +249 -18
- package/src/metro/transformer.ts +28 -3
- package/src/metro/with-config.ts +23 -1
package/src/metro/state.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* when the
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
392
|
-
* `end={…}`
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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
|
|
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
|
|
403
|
-
for (const attribute of
|
|
404
|
-
|
|
405
|
-
if (!t.
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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:
|
package/src/metro/transformer.ts
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
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
|
/**
|
package/src/metro/with-config.ts
CHANGED
|
@@ -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
|