@tamagui/to-tailwind 0.0.0-bootstrap.0 → 3.0.0-beta.643.1
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/dist/cjs/index.cjs +30 -0
- package/dist/cjs/index.native.cjs +32 -0
- package/dist/cjs/index.native.js +34 -0
- package/dist/cjs/index.native.js.map +1 -0
- package/dist/cjs/maps/componentToTag.cjs +42 -0
- package/dist/cjs/maps/componentToTag.native.cjs +44 -0
- package/dist/cjs/maps/componentToTag.native.js +46 -0
- package/dist/cjs/maps/componentToTag.native.js.map +1 -0
- package/dist/cjs/transform.cjs +706 -0
- package/dist/cjs/transform.native.cjs +933 -0
- package/dist/cjs/transform.native.js +935 -0
- package/dist/cjs/transform.native.js.map +1 -0
- package/dist/esm/index.mjs +4 -0
- package/dist/esm/index.native.js +4 -0
- package/dist/esm/maps/componentToTag.mjs +21 -0
- package/dist/esm/maps/componentToTag.mjs.map +1 -0
- package/dist/esm/maps/componentToTag.native.js +21 -0
- package/dist/esm/maps/componentToTag.native.js.map +1 -0
- package/dist/esm/transform.mjs +677 -0
- package/dist/esm/transform.mjs.map +1 -0
- package/dist/esm/transform.native.js +902 -0
- package/dist/esm/transform.native.js.map +1 -0
- package/package.json +44 -7
- package/src/index.ts +2 -0
- package/src/maps/componentToTag.ts +18 -0
- package/src/transform.ts +889 -0
- package/README.md +0 -1
package/src/transform.ts
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { parse } from '@babel/parser'
|
|
2
|
+
import _traverse from '@babel/traverse'
|
|
3
|
+
import _generate from '@babel/generator'
|
|
4
|
+
import * as t from '@babel/types'
|
|
5
|
+
import {
|
|
6
|
+
defaultMediaKeys,
|
|
7
|
+
createModifierRegistry,
|
|
8
|
+
fontWeightNames,
|
|
9
|
+
formatCandidate,
|
|
10
|
+
getTokenCategory,
|
|
11
|
+
propToTailwindPrefix,
|
|
12
|
+
parseValue,
|
|
13
|
+
standaloneValueProps,
|
|
14
|
+
type GrammarConfigView,
|
|
15
|
+
type ModifierRegistryView,
|
|
16
|
+
type TokenCategory,
|
|
17
|
+
v6RemovedThemeNames,
|
|
18
|
+
v6ThemeNameReplacements,
|
|
19
|
+
} from '@tamagui/style-grammar/tooling'
|
|
20
|
+
import { componentToTag } from './maps/componentToTag'
|
|
21
|
+
// CANONICAL default shorthands — a STATIC import (declared dep), ESM+CJS-safe, single owner.
|
|
22
|
+
// (replaces a module-global lazy `require('@tamagui/shorthands/v4')`, which was the same
|
|
23
|
+
// ESM-undefined / non-reentrant hazard as the tokens require.)
|
|
24
|
+
import { shorthands as canonicalShorthands } from '@tamagui/shorthands/v4'
|
|
25
|
+
|
|
26
|
+
const traverse = (_traverse as any).default ?? _traverse
|
|
27
|
+
const generate = (_generate as any).default ?? _generate
|
|
28
|
+
|
|
29
|
+
const defaultShorthands = canonicalShorthands as Record<string, string>
|
|
30
|
+
const removedV6ThemeNames = new Set<string>(v6RemovedThemeNames)
|
|
31
|
+
|
|
32
|
+
function getV6ThemeName(name: string): string | null {
|
|
33
|
+
const opacityIndex = name.indexOf('/')
|
|
34
|
+
const baseName = opacityIndex === -1 ? name : name.slice(0, opacityIndex)
|
|
35
|
+
if (removedV6ThemeNames.has(baseName)) return null
|
|
36
|
+
const mapped =
|
|
37
|
+
v6ThemeNameReplacements[baseName as keyof typeof v6ThemeNameReplacements] ?? baseName
|
|
38
|
+
return opacityIndex === -1 ? mapped : `${mapped}${name.slice(opacityIndex)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function namesToSet(names: GrammarConfigView['mediaNames']): Set<string> {
|
|
42
|
+
if (!names) return new Set()
|
|
43
|
+
if (Array.isArray(names)) return new Set(names)
|
|
44
|
+
if (names instanceof Set) return new Set(names)
|
|
45
|
+
return new Set(Object.keys(names))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function addConfigNames(
|
|
49
|
+
target: Set<string>,
|
|
50
|
+
source: Record<string, any> | undefined
|
|
51
|
+
): void {
|
|
52
|
+
if (!source) return
|
|
53
|
+
for (const key in source) target.add(key)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createTransformGrammarConfig(
|
|
57
|
+
options: TransformOptions,
|
|
58
|
+
mediaKeys: Set<string>,
|
|
59
|
+
shorthands: Record<string, string>
|
|
60
|
+
): GrammarConfigView {
|
|
61
|
+
if (options.grammarConfig) {
|
|
62
|
+
return { ...options.grammarConfig, shorthands, mediaNames: mediaKeys }
|
|
63
|
+
}
|
|
64
|
+
const tokenNames: Partial<Record<TokenCategory, Set<string>>> = {}
|
|
65
|
+
const tokenCategories = ['space', 'size', 'radius', 'zIndex', 'color'] as const
|
|
66
|
+
const fontCategories = [
|
|
67
|
+
'fontFamily',
|
|
68
|
+
'fontSize',
|
|
69
|
+
'fontWeight',
|
|
70
|
+
'lineHeight',
|
|
71
|
+
'letterSpacing',
|
|
72
|
+
] as const
|
|
73
|
+
// Supplying any token/font/theme domain means this is an authoritative config view: omitted
|
|
74
|
+
// domains are known-empty. With no such fields, config-less conversion remains conservative but
|
|
75
|
+
// may emit unambiguous token names whose membership cannot be proved until runtime.
|
|
76
|
+
const authoritative = ['tokens', 'fonts', 'themes', 'media', 'shorthands'].some((key) =>
|
|
77
|
+
Object.prototype.hasOwnProperty.call(options, key)
|
|
78
|
+
)
|
|
79
|
+
if (authoritative) {
|
|
80
|
+
for (const category of [...tokenCategories, ...fontCategories]) {
|
|
81
|
+
tokenNames[category] = new Set<string>()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const category of tokenCategories) {
|
|
85
|
+
if (options.tokens?.[category]) {
|
|
86
|
+
const names = tokenNames[category] || new Set<string>()
|
|
87
|
+
addConfigNames(names, options.tokens[category])
|
|
88
|
+
tokenNames[category] = names
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (options.fonts) {
|
|
92
|
+
tokenNames.fontFamily ||= new Set<string>()
|
|
93
|
+
tokenNames.fontSize ||= new Set<string>()
|
|
94
|
+
tokenNames.fontWeight ||= new Set<string>()
|
|
95
|
+
tokenNames.lineHeight ||= new Set<string>()
|
|
96
|
+
tokenNames.letterSpacing ||= new Set<string>()
|
|
97
|
+
for (const familyName in options.fonts) {
|
|
98
|
+
tokenNames.fontFamily.add(familyName)
|
|
99
|
+
const font = options.fonts[familyName]
|
|
100
|
+
addConfigNames(tokenNames.fontSize, font?.size)
|
|
101
|
+
addConfigNames(tokenNames.fontWeight, font?.weight)
|
|
102
|
+
addConfigNames(tokenNames.lineHeight, font?.lineHeight)
|
|
103
|
+
addConfigNames(tokenNames.letterSpacing, font?.letterSpacing)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const themeNames = new Set<string>()
|
|
107
|
+
if (options.themes) {
|
|
108
|
+
tokenNames.color ||= new Set<string>()
|
|
109
|
+
for (const themeName in options.themes) {
|
|
110
|
+
themeNames.add(themeName)
|
|
111
|
+
for (const name in options.themes[themeName]) {
|
|
112
|
+
const mapped = getV6ThemeName(name)
|
|
113
|
+
if (mapped) tokenNames.color.add(mapped)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
shorthands,
|
|
119
|
+
mediaNames: mediaKeys,
|
|
120
|
+
themeNames,
|
|
121
|
+
tokenNames,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface TransformOptions {
|
|
126
|
+
// rename View→div, Text→span, etc. DEFAULT true for the library (tamagui.dev doc snippets);
|
|
127
|
+
// the CLI path passes false so cross-platform Tamagui components are preserved.
|
|
128
|
+
renameComponents?: boolean
|
|
129
|
+
// the app config's `media` (object or key list). ANY configured media key round-trips as an
|
|
130
|
+
// identity modifier (`tablet:` stays `tablet:`). when omitted, a default key set is the fallback.
|
|
131
|
+
media?: Record<string, any> | string[]
|
|
132
|
+
// extra component names (beyond the built-in Tamagui set) whose props may be converted, e.g.
|
|
133
|
+
// an app's `styled()` outputs, or member paths like "Sheet.Container". arbitrary components
|
|
134
|
+
// stay untouched unless listed.
|
|
135
|
+
components?: string[]
|
|
136
|
+
// the app config's shorthands (short → long, e.g. { bg: 'backgroundColor' }). when omitted,
|
|
137
|
+
// the canonical default shorthands are used. threaded (not a module-global require).
|
|
138
|
+
shorthands?: Record<string, string>
|
|
139
|
+
// Only token/font/theme NAMES are read. Values remain runtime-owned.
|
|
140
|
+
tokens?: Record<string, Record<string, any>>
|
|
141
|
+
fonts?: Record<string, any>
|
|
142
|
+
themes?: Record<string, Record<string, any>>
|
|
143
|
+
// Precomputed names-only view for dependency-free callers such as CLI bundled defaults.
|
|
144
|
+
grammarConfig?: GrammarConfigView
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// per-call context (threaded, NOT module-global) so the converter is PURE and REENTRANT:
|
|
148
|
+
// two conversions with different configs in one process never clash.
|
|
149
|
+
interface Ctx {
|
|
150
|
+
mediaKeys: Set<string>
|
|
151
|
+
componentAllow: Set<string>
|
|
152
|
+
shorthands: Record<string, string>
|
|
153
|
+
grammarConfig: GrammarConfigView
|
|
154
|
+
modifierRegistry: ModifierRegistryView
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* find recoverable parse errors in `source` (@babel errorRecovery). returns a short location
|
|
159
|
+
* string for the first error, or null if the source parses cleanly. the CLI uses this to ABORT
|
|
160
|
+
* (transactionally) before writing, so malformed source is never normalized/partially rewritten.
|
|
161
|
+
*/
|
|
162
|
+
export function findParseError(source: string): string | null {
|
|
163
|
+
try {
|
|
164
|
+
const ast = parse(source, {
|
|
165
|
+
sourceType: 'module',
|
|
166
|
+
plugins: ['jsx', 'typescript'],
|
|
167
|
+
errorRecovery: true,
|
|
168
|
+
})
|
|
169
|
+
const errs = (ast as any).errors as any[] | undefined
|
|
170
|
+
if (errs && errs.length > 0) {
|
|
171
|
+
const e = errs[0]
|
|
172
|
+
const loc = e?.loc ? `${e.loc.line}:${e.loc.column}` : '?'
|
|
173
|
+
return `${e?.reasonCode || e?.message || 'parse error'} at ${loc}`
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return (e as Error).message || 'parse error'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* converts tamagui JSX source code to tailwind className syntax.
|
|
183
|
+
*
|
|
184
|
+
* input: <View backgroundColor="red" padding={10} opacity="1 hover:0.8" />
|
|
185
|
+
* output: <div className="bg-[red] p-[10px] opacity-100 hover:opacity-80" />
|
|
186
|
+
*/
|
|
187
|
+
export function tamaguiToTailwind(
|
|
188
|
+
source: string,
|
|
189
|
+
options: TransformOptions = {}
|
|
190
|
+
): string {
|
|
191
|
+
const { renameComponents = true } = options
|
|
192
|
+
|
|
193
|
+
const mediaKeys = options.grammarConfig?.mediaNames
|
|
194
|
+
? namesToSet(options.grammarConfig.mediaNames)
|
|
195
|
+
: options.media
|
|
196
|
+
? new Set(Array.isArray(options.media) ? options.media : Object.keys(options.media))
|
|
197
|
+
: new Set(defaultMediaKeys)
|
|
198
|
+
const shorthands =
|
|
199
|
+
options.grammarConfig?.shorthands ?? options.shorthands ?? defaultShorthands
|
|
200
|
+
const grammarConfig = createTransformGrammarConfig(options, mediaKeys, shorthands)
|
|
201
|
+
const ctx: Ctx = {
|
|
202
|
+
mediaKeys,
|
|
203
|
+
componentAllow: new Set(options.components ?? []),
|
|
204
|
+
shorthands,
|
|
205
|
+
grammarConfig,
|
|
206
|
+
modifierRegistry: createModifierRegistry(grammarConfig).registry,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let ast: t.File
|
|
210
|
+
try {
|
|
211
|
+
ast = parse(source, {
|
|
212
|
+
sourceType: 'module',
|
|
213
|
+
plugins: ['jsx', 'typescript'],
|
|
214
|
+
errorRecovery: true,
|
|
215
|
+
})
|
|
216
|
+
} catch {
|
|
217
|
+
return source // unparseable → leave untouched, never rewrite
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// recoverable parse errors → do NOT transform (babel errorRecovery would otherwise normalize +
|
|
221
|
+
// partially rewrite malformed source). return unchanged; the CLI aborts via findParseError.
|
|
222
|
+
if ((ast as any).errors && (ast as any).errors.length > 0) {
|
|
223
|
+
return source
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// true no-op: if NOTHING was semantically converted, return the ORIGINAL source byte-for-byte.
|
|
227
|
+
// @babel/generator re-serializes the WHOLE file (whitespace/semicolons/quotes), so a no-target
|
|
228
|
+
// or arbitrary-component file would otherwise be silently reformatted — and a --write batch
|
|
229
|
+
// would normalize every parseable file in the codebase. only files with real conversions churn.
|
|
230
|
+
let didTransform = false
|
|
231
|
+
|
|
232
|
+
traverse(ast, {
|
|
233
|
+
JSXOpeningElement(path) {
|
|
234
|
+
const node = path.node
|
|
235
|
+
|
|
236
|
+
// BINDING-AWARE resolution: what tamagui component (if any) does this JSX name refer to?
|
|
237
|
+
// uses Babel scope so a LOCAL `View` or a non-tamagui import is NEVER rewritten (that would
|
|
238
|
+
// be destructive data-loss), while tamagui aliases (`View as TamaView`) + namespace members
|
|
239
|
+
// (`import * as T from 'tamagui'; <T.View>`) ARE recognized.
|
|
240
|
+
const resolved = resolveTamaguiComponent(path, ctx)
|
|
241
|
+
if (resolved === null) return
|
|
242
|
+
const { name: componentName, isSimpleKnown, isPlainIdentifier } = resolved
|
|
243
|
+
|
|
244
|
+
// SPREAD-CONSERVATIVE: a spread makes attribute PRECEDENCE order-dependent
|
|
245
|
+
// (<View {...p} pad/> vs <View pad {...p}/> mean opposite things), and a spread may carry
|
|
246
|
+
// className or the same style key. we can't safely merge/reorder without evaluating the
|
|
247
|
+
// spread, so we do NOT convert props on any element that has a spread — left untouched.
|
|
248
|
+
if (node.attributes.some((a) => t.isJSXSpreadAttribute(a))) return
|
|
249
|
+
|
|
250
|
+
// A dynamic class may overlap any neighboring style prop. Combining everything into one
|
|
251
|
+
// generated className would move that contribution and violate authored forward order, so
|
|
252
|
+
// retain the element exactly as written.
|
|
253
|
+
const hasDynamicClassName = node.attributes.some(
|
|
254
|
+
(attr) =>
|
|
255
|
+
t.isJSXAttribute(attr) &&
|
|
256
|
+
attr.name.name === 'className' &&
|
|
257
|
+
getStringValue(attr.value) === null
|
|
258
|
+
)
|
|
259
|
+
if (hasDynamicClassName) return
|
|
260
|
+
|
|
261
|
+
const hasClassName = node.attributes.some(
|
|
262
|
+
(attr) => t.isJSXAttribute(attr) && attr.name.name === 'className'
|
|
263
|
+
)
|
|
264
|
+
const hasNeighboringStyleProp = node.attributes.some((attr) => {
|
|
265
|
+
if (!t.isJSXAttribute(attr) || attr.name.name === 'className') return false
|
|
266
|
+
const name = attr.name.name as string
|
|
267
|
+
return isConvertibleStyleProp(resolveShorthand(ctx, name))
|
|
268
|
+
})
|
|
269
|
+
if (hasClassName && hasNeighboringStyleProp) return
|
|
270
|
+
|
|
271
|
+
const classes: string[] = []
|
|
272
|
+
const keptAttrs: t.JSXAttribute[] = []
|
|
273
|
+
let existingClassName: t.JSXAttribute | null = null
|
|
274
|
+
|
|
275
|
+
// Pre-scan a static existing className for prop-prefix collisions. Overlapping values stay
|
|
276
|
+
// authored as separate props so the runtime forward pass can preserve their source order.
|
|
277
|
+
const blockedLeafKeys = new Set<string>()
|
|
278
|
+
for (const a of node.attributes) {
|
|
279
|
+
if (t.isJSXAttribute(a) && a.name.name === 'className') {
|
|
280
|
+
const v = getStringValue(a.value)
|
|
281
|
+
if (v) {
|
|
282
|
+
for (const tok of v.split(/\s+/)) {
|
|
283
|
+
if (tok) for (const k of leafKeysOfClassToken(tok)) blockedLeafKeys.add(k)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// implicit component-default classes (lowest precedence — filtered against overlaps below)
|
|
290
|
+
const implicitClasses: string[] = []
|
|
291
|
+
if (componentName === 'XStack') implicitClasses.push('flex', 'flex-row')
|
|
292
|
+
else if (componentName === 'YStack') implicitClasses.push('flex', 'flex-col')
|
|
293
|
+
else if (componentName === 'ZStack') implicitClasses.push('relative')
|
|
294
|
+
|
|
295
|
+
// pass 1: route non-style attrs immediately; DEFER base style props for the overlap pass
|
|
296
|
+
const styleEntries: {
|
|
297
|
+
attr: t.JSXAttribute
|
|
298
|
+
fullProp: string
|
|
299
|
+
cls: string | null
|
|
300
|
+
}[] = []
|
|
301
|
+
for (const attr of node.attributes) {
|
|
302
|
+
if (!t.isJSXAttribute(attr)) {
|
|
303
|
+
keptAttrs.push(attr as any)
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
const name = attr.name.name as string
|
|
307
|
+
|
|
308
|
+
if (name === 'className') {
|
|
309
|
+
existingClassName = attr
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
// base style prop — defer
|
|
313
|
+
styleEntries.push({
|
|
314
|
+
attr,
|
|
315
|
+
fullProp: resolveShorthand(ctx, name),
|
|
316
|
+
cls: propValueToClass(ctx, name, attr.value),
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// pass 2: a RETAINED style prop (dynamic/unconvertible) blocks any overlapping conversion
|
|
321
|
+
const retainedLeafKeys = new Set(blockedLeafKeys)
|
|
322
|
+
for (const e of styleEntries) {
|
|
323
|
+
if (e.cls === null && isConvertibleStyleProp(e.fullProp)) {
|
|
324
|
+
for (const k of leafKeysOfProp(e.fullProp)) retainedLeafKeys.add(k)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// implicit defaults first (lowest precedence), suppressed if they'd override a retained prop
|
|
328
|
+
for (const c of implicitClasses) {
|
|
329
|
+
if (!overlapsSet(leafKeysOfClassToken(c), retainedLeafKeys)) classes.push(c)
|
|
330
|
+
}
|
|
331
|
+
// then the base props: convert only when NON-overlapping; otherwise RETAIN (a class would
|
|
332
|
+
// beat the retained longhand/dynamic prop and flip precedence)
|
|
333
|
+
for (const e of styleEntries) {
|
|
334
|
+
if (
|
|
335
|
+
e.cls !== null &&
|
|
336
|
+
!overlapsSet(leafKeysOfProp(e.fullProp), retainedLeafKeys)
|
|
337
|
+
) {
|
|
338
|
+
classes.push(e.cls)
|
|
339
|
+
} else {
|
|
340
|
+
keptAttrs.push(e.attr)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// build the new className, PRESERVING an existing static string or dynamic expression
|
|
345
|
+
if (classes.length > 0) {
|
|
346
|
+
const classStr = classes.join(' ')
|
|
347
|
+
if (existingClassName) {
|
|
348
|
+
const existingVal = getStringValue(existingClassName.value)
|
|
349
|
+
if (existingVal !== null) {
|
|
350
|
+
// the existing user className must win in the Tailwind frontend, so it goes last (later classes
|
|
351
|
+
// override earlier for the same prop). generated classes — including the implicit
|
|
352
|
+
// XStack `flex-row` default — come FIRST so an explicit user class overrides them.
|
|
353
|
+
existingClassName.value = t.stringLiteral(
|
|
354
|
+
existingVal ? `${classStr} ${existingVal}` : classStr
|
|
355
|
+
)
|
|
356
|
+
keptAttrs.unshift(existingClassName)
|
|
357
|
+
} else if (
|
|
358
|
+
t.isJSXExpressionContainer(existingClassName.value) &&
|
|
359
|
+
!t.isJSXEmptyExpression(existingClassName.value.expression)
|
|
360
|
+
) {
|
|
361
|
+
// DYNAMIC className expression → COMBINE via template literal `classes ${expr}` with
|
|
362
|
+
// the expression last so it wins (can't inspect it; className-wins is the Tailwind
|
|
363
|
+
// rule). never overwrite (the old code silently replaced it with a static string).
|
|
364
|
+
const expr = existingClassName.value.expression as t.Expression
|
|
365
|
+
existingClassName.value = t.jsxExpressionContainer(
|
|
366
|
+
t.templateLiteral(
|
|
367
|
+
[
|
|
368
|
+
t.templateElement(
|
|
369
|
+
{ raw: `${classStr} `, cooked: `${classStr} ` },
|
|
370
|
+
false
|
|
371
|
+
),
|
|
372
|
+
t.templateElement({ raw: '', cooked: '' }, true),
|
|
373
|
+
],
|
|
374
|
+
[expr]
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
keptAttrs.unshift(existingClassName)
|
|
378
|
+
} else {
|
|
379
|
+
keptAttrs.unshift(
|
|
380
|
+
t.jsxAttribute(t.jsxIdentifier('className'), t.stringLiteral(classStr))
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
keptAttrs.unshift(
|
|
385
|
+
t.jsxAttribute(t.jsxIdentifier('className'), t.stringLiteral(classStr))
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
} else if (existingClassName) {
|
|
389
|
+
keptAttrs.unshift(existingClassName)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
node.attributes = keptAttrs
|
|
393
|
+
if (classes.length > 0) didTransform = true // a className was added/merged
|
|
394
|
+
|
|
395
|
+
// rename ONLY a plain-identifier simple-known component (never a member path like T.View),
|
|
396
|
+
// and only when opted in. rename the OPENING and CLOSING tag TOGETHER here — an element we
|
|
397
|
+
// RETURN early on (spread / unknown / non-tamagui binding) is never reached, so its closing
|
|
398
|
+
// is never renamed (the old separate JSXClosingElement visitor renamed closings of skipped
|
|
399
|
+
// elements → `</div>` under a kept `<View>`: mismatched-tag SYNTAX CORRUPTION).
|
|
400
|
+
if (renameComponents && isSimpleKnown && isPlainIdentifier) {
|
|
401
|
+
const tag = componentToTag[componentName]
|
|
402
|
+
node.name = t.jsxIdentifier(tag)
|
|
403
|
+
const parent = path.parent
|
|
404
|
+
if (t.isJSXElement(parent) && parent.closingElement) {
|
|
405
|
+
parent.closingElement.name = t.jsxIdentifier(tag)
|
|
406
|
+
}
|
|
407
|
+
didTransform = true
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
if (!didTransform) return source // true no-op → original bytes (no generator reformat)
|
|
413
|
+
|
|
414
|
+
const output = generate(ast, { retainLines: true, concise: false })
|
|
415
|
+
return output.code
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── helpers ──────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
// known Tamagui COMPOUND (member-path) surfaces that accept style props. unknown namespaces
|
|
421
|
+
// (Chart.Axis, MyLib.Thing) are left untouched; a caller can extend via `components`.
|
|
422
|
+
const knownCompoundComponents = new Set<string>([
|
|
423
|
+
'Sheet.Frame',
|
|
424
|
+
'Sheet.Overlay',
|
|
425
|
+
'Sheet.Handle',
|
|
426
|
+
'Sheet.ScrollView',
|
|
427
|
+
'Sheet.Container',
|
|
428
|
+
'Dialog.Content',
|
|
429
|
+
'Dialog.Overlay',
|
|
430
|
+
'Popover.Content',
|
|
431
|
+
'Tabs.Tab',
|
|
432
|
+
])
|
|
433
|
+
|
|
434
|
+
// a module specifier counts as "tamagui" if it's the umbrella package or any @tamagui/* scope.
|
|
435
|
+
function isTamaguiSource(source: string): boolean {
|
|
436
|
+
return source === 'tamagui' || source.startsWith('@tamagui/')
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* BINDING-AWARE component resolution. maps a JSX element name to the TAMAGUI component name it
|
|
441
|
+
* refers to (or null to skip), using Babel scope so provenance — not spelling — decides:
|
|
442
|
+
* - a LOCAL binding (const/let/function) or a NON-tamagui import → null (never rewrite; a
|
|
443
|
+
* local `<View>` or `<Sheet.Frame>` is someone else's component — mutating it is data-loss)
|
|
444
|
+
* - a tamagui named import, incl. an ALIAS (`View as TamaView` → "View")
|
|
445
|
+
* - a tamagui NAMESPACE member (`import * as T from 'tamagui'; <T.View>` → "View")
|
|
446
|
+
* - an UNBOUND name (bare snippet, no imports) → legacy built-in assumption (API-compat)
|
|
447
|
+
* `name` is the resolved tamagui name ("View" / "Sheet.Frame"); `isSimpleKnown` gates rename;
|
|
448
|
+
* `isPlainIdentifier` is false for member paths (never DOM-renamed).
|
|
449
|
+
*/
|
|
450
|
+
function resolveTamaguiComponent(
|
|
451
|
+
path: any,
|
|
452
|
+
ctx: Ctx
|
|
453
|
+
): { name: string; isSimpleKnown: boolean; isPlainIdentifier: boolean } | null {
|
|
454
|
+
const nameNode = path.node.name as t.JSXOpeningElement['name']
|
|
455
|
+
|
|
456
|
+
// extract base identifier + member chain (<T.View> → base "T", chain ["View"])
|
|
457
|
+
let baseName: string
|
|
458
|
+
let memberChain: string[] = []
|
|
459
|
+
const isPlainIdentifier = t.isJSXIdentifier(nameNode)
|
|
460
|
+
if (t.isJSXIdentifier(nameNode)) {
|
|
461
|
+
baseName = nameNode.name
|
|
462
|
+
} else if (t.isJSXMemberExpression(nameNode)) {
|
|
463
|
+
const parts: string[] = []
|
|
464
|
+
let cur: t.JSXMemberExpression | t.JSXIdentifier = nameNode
|
|
465
|
+
while (t.isJSXMemberExpression(cur)) {
|
|
466
|
+
parts.unshift(cur.property.name)
|
|
467
|
+
cur = cur.object
|
|
468
|
+
}
|
|
469
|
+
if (!t.isJSXIdentifier(cur)) return null
|
|
470
|
+
baseName = cur.name
|
|
471
|
+
memberChain = parts
|
|
472
|
+
} else {
|
|
473
|
+
return null
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const binding = path.scope?.getBinding?.(baseName)
|
|
477
|
+
let tamaguiBase: string
|
|
478
|
+
|
|
479
|
+
if (binding) {
|
|
480
|
+
const bnode = binding.path.node
|
|
481
|
+
if (
|
|
482
|
+
t.isImportSpecifier(bnode) ||
|
|
483
|
+
t.isImportDefaultSpecifier(bnode) ||
|
|
484
|
+
t.isImportNamespaceSpecifier(bnode)
|
|
485
|
+
) {
|
|
486
|
+
const decl = binding.path.parent
|
|
487
|
+
const source = t.isImportDeclaration(decl) ? String(decl.source.value) : ''
|
|
488
|
+
if (!isTamaguiSource(source)) return null // imported from a NON-tamagui module → skip
|
|
489
|
+
if (t.isImportNamespaceSpecifier(bnode)) {
|
|
490
|
+
// import * as T from 'tamagui' — only <T.Something> is a component
|
|
491
|
+
if (memberChain.length === 0) return null
|
|
492
|
+
tamaguiBase = memberChain[0]
|
|
493
|
+
memberChain = memberChain.slice(1)
|
|
494
|
+
} else if (t.isImportSpecifier(bnode)) {
|
|
495
|
+
// import { View as TamaView } — the IMPORTED name is the tamagui name
|
|
496
|
+
const imported = bnode.imported
|
|
497
|
+
tamaguiBase = t.isIdentifier(imported) ? imported.name : String(imported.value)
|
|
498
|
+
} else {
|
|
499
|
+
return null // default import from tamagui isn't a named component
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
return null // LOCAL binding (const/let/function/param) → not tamagui
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
tamaguiBase = baseName // UNBOUND → legacy built-in assumption
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const name = memberChain.length ? [tamaguiBase, ...memberChain].join('.') : tamaguiBase
|
|
509
|
+
const isSimpleKnown = memberChain.length === 0 && name in componentToTag
|
|
510
|
+
|
|
511
|
+
// gate: known simple, known compound, or caller allowlist — else skip
|
|
512
|
+
if (
|
|
513
|
+
!isSimpleKnown &&
|
|
514
|
+
!knownCompoundComponents.has(name) &&
|
|
515
|
+
!ctx.componentAllow.has(name)
|
|
516
|
+
) {
|
|
517
|
+
return null
|
|
518
|
+
}
|
|
519
|
+
return { name, isSimpleKnown, isPlainIdentifier }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// the prop-prefix a class token targets (modifiers + negation stripped): "hover:p-2" → "p",
|
|
523
|
+
// "-mt-1" → "mt", "hidden" → "hidden".
|
|
524
|
+
function classPrefixOf(token: string): string {
|
|
525
|
+
const noMod = token.slice(token.lastIndexOf(':') + 1)
|
|
526
|
+
const core = noMod[0] === '-' ? noMod.slice(1) : noMod
|
|
527
|
+
const dash = core.indexOf('-')
|
|
528
|
+
return dash === -1 ? core : core.slice(0, dash)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── overlap detection (shorthand ↔ longhand ↔ axis ↔ corner) ──
|
|
532
|
+
// the LEAF style keys a prop resolves to. two props "overlap" if these intersect. used so a
|
|
533
|
+
// converted class never overrides a RETAINED (dynamic) prop that shares a key (className always
|
|
534
|
+
// beats converted props, so overlap can only be avoided, not ordered around).
|
|
535
|
+
const leafExpansion: Record<string, string[]> = {
|
|
536
|
+
padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
|
|
537
|
+
paddingHorizontal: ['paddingLeft', 'paddingRight'],
|
|
538
|
+
paddingVertical: ['paddingTop', 'paddingBottom'],
|
|
539
|
+
margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
|
|
540
|
+
marginHorizontal: ['marginLeft', 'marginRight'],
|
|
541
|
+
marginVertical: ['marginTop', 'marginBottom'],
|
|
542
|
+
inset: ['top', 'right', 'bottom', 'left'],
|
|
543
|
+
gap: ['gap', 'rowGap', 'columnGap'],
|
|
544
|
+
borderWidth: [
|
|
545
|
+
'borderTopWidth',
|
|
546
|
+
'borderRightWidth',
|
|
547
|
+
'borderBottomWidth',
|
|
548
|
+
'borderLeftWidth',
|
|
549
|
+
],
|
|
550
|
+
borderColor: [
|
|
551
|
+
'borderTopColor',
|
|
552
|
+
'borderRightColor',
|
|
553
|
+
'borderBottomColor',
|
|
554
|
+
'borderLeftColor',
|
|
555
|
+
],
|
|
556
|
+
borderRadius: [
|
|
557
|
+
'borderTopLeftRadius',
|
|
558
|
+
'borderTopRightRadius',
|
|
559
|
+
'borderBottomLeftRadius',
|
|
560
|
+
'borderBottomRightRadius',
|
|
561
|
+
],
|
|
562
|
+
}
|
|
563
|
+
function leafKeysOfProp(fullProp: string): string[] {
|
|
564
|
+
return leafExpansion[fullProp] ?? [fullProp]
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// reverse tailwind prefix → prop, for reading an existing className's occupied keys. first prop
|
|
568
|
+
// wins for a shared prefix (conservative — over-retention is safe).
|
|
569
|
+
const prefixToProp: Record<string, string> = (() => {
|
|
570
|
+
const m: Record<string, string> = {}
|
|
571
|
+
for (const [prop, prefix] of Object.entries(propToTailwindPrefix)) {
|
|
572
|
+
if (prefix && !(prefix in m)) m[prefix] = prop
|
|
573
|
+
}
|
|
574
|
+
return m
|
|
575
|
+
})()
|
|
576
|
+
function leafKeysOfClassToken(token: string): string[] {
|
|
577
|
+
const prefix = classPrefixOf(token)
|
|
578
|
+
return leafKeysOfProp(prefixToProp[prefix] ?? prefix)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function overlapsSet(keys: string[], set: Set<string>): boolean {
|
|
582
|
+
return keys.some((k) => set.has(k))
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// a prop the converter recognizes as a style prop (so its leaf keys matter for overlap)
|
|
586
|
+
function isConvertibleStyleProp(fullProp: string): boolean {
|
|
587
|
+
return propToTailwindPrefix[fullProp] !== undefined || fullProp in standaloneValueProps
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function propValueToClass(
|
|
591
|
+
ctx: Ctx,
|
|
592
|
+
propName: string,
|
|
593
|
+
value: t.JSXAttribute['value']
|
|
594
|
+
): string | null {
|
|
595
|
+
const fullProp = resolveShorthand(ctx, propName)
|
|
596
|
+
|
|
597
|
+
if (propToTailwindPrefix[fullProp] === undefined) return null
|
|
598
|
+
|
|
599
|
+
const strVal = getStringValue(value)
|
|
600
|
+
const numVal = getNumericValue(value)
|
|
601
|
+
|
|
602
|
+
if (strVal !== null) {
|
|
603
|
+
const parsed = parseValue(strVal, ctx.modifierRegistry)
|
|
604
|
+
if (!parsed.ok) return null
|
|
605
|
+
const classes: string[] = []
|
|
606
|
+
if (parsed.value.base !== null) {
|
|
607
|
+
const base = payloadToClass(ctx, fullProp, parsed.value.base, [])
|
|
608
|
+
if (base === null) return null
|
|
609
|
+
classes.push(base)
|
|
610
|
+
}
|
|
611
|
+
for (const clause of parsed.value.clauses) {
|
|
612
|
+
const conditional = payloadToClass(ctx, fullProp, clause.payload, clause.modifiers)
|
|
613
|
+
if (conditional === null) return null
|
|
614
|
+
classes.push(conditional)
|
|
615
|
+
}
|
|
616
|
+
return classes.length ? classes.join(' ') : null
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (numVal === null) return null // dynamic expression → retain
|
|
620
|
+
const formatted = formatNumericValue(fullProp, numVal)
|
|
621
|
+
if (formatted === null) return null
|
|
622
|
+
|
|
623
|
+
return formatCandidate(
|
|
624
|
+
{
|
|
625
|
+
prop: fullProp,
|
|
626
|
+
value: formatted.value,
|
|
627
|
+
valueKind: formatted.valueKind,
|
|
628
|
+
modifiers: undefined,
|
|
629
|
+
},
|
|
630
|
+
ctx.grammarConfig
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function payloadToClass(
|
|
635
|
+
ctx: Ctx,
|
|
636
|
+
fullProp: string,
|
|
637
|
+
payload: string,
|
|
638
|
+
modifiers: readonly string[]
|
|
639
|
+
): string | null {
|
|
640
|
+
let formatted: FormattedValue | null
|
|
641
|
+
if (standaloneValueProps[fullProp]?.[payload]) {
|
|
642
|
+
formatted = { value: payload, valueKind: 'enum' }
|
|
643
|
+
} else {
|
|
644
|
+
formatted = formatStringValue(ctx, fullProp, payload)
|
|
645
|
+
if (formatted === null && /^-?\d+(?:\.\d+)?$/.test(payload)) {
|
|
646
|
+
const category = getTokenCategory(fullProp)
|
|
647
|
+
if (!category || ctx.grammarConfig.tokenNames?.[category] !== undefined) {
|
|
648
|
+
formatted = formatNumericValue(fullProp, Number(payload))
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (formatted === null) return null
|
|
653
|
+
return formatCandidate(
|
|
654
|
+
{
|
|
655
|
+
prop: fullProp,
|
|
656
|
+
value: formatted.value,
|
|
657
|
+
valueKind: formatted.valueKind,
|
|
658
|
+
modifiers: modifiers.length ? modifiers : undefined,
|
|
659
|
+
},
|
|
660
|
+
ctx.grammarConfig
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function getStringValue(value: t.JSXAttribute['value']): string | null {
|
|
665
|
+
if (!value) return null
|
|
666
|
+
if (t.isStringLiteral(value)) return value.value
|
|
667
|
+
if (t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression)) {
|
|
668
|
+
return value.expression.value
|
|
669
|
+
}
|
|
670
|
+
if (t.isJSXExpressionContainer(value) && t.isTemplateLiteral(value.expression)) {
|
|
671
|
+
if (
|
|
672
|
+
value.expression.expressions.length === 0 &&
|
|
673
|
+
value.expression.quasis.length === 1
|
|
674
|
+
) {
|
|
675
|
+
return value.expression.quasis[0].value.raw
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return null
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function getNumericValue(value: t.JSXAttribute['value']): number | null {
|
|
682
|
+
if (!value) return null
|
|
683
|
+
if (t.isJSXExpressionContainer(value)) {
|
|
684
|
+
if (t.isNumericLiteral(value.expression)) return value.expression.value
|
|
685
|
+
if (
|
|
686
|
+
t.isUnaryExpression(value.expression) &&
|
|
687
|
+
value.expression.operator === '-' &&
|
|
688
|
+
t.isNumericLiteral(value.expression.argument)
|
|
689
|
+
) {
|
|
690
|
+
return -value.expression.argument.value
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return null
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function resolveShorthand(ctx: Ctx, name: string): string {
|
|
697
|
+
return ctx.shorthands[name] || name
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
type FormattedValue = {
|
|
701
|
+
value: string
|
|
702
|
+
valueKind: 'token' | 'arbitrary' | 'enum' | 'convenience'
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function hasConfiguredName(
|
|
706
|
+
names: NonNullable<GrammarConfigView['tokenNames']>[TokenCategory] | undefined,
|
|
707
|
+
value: string
|
|
708
|
+
): boolean {
|
|
709
|
+
if (!names) return false
|
|
710
|
+
if (Array.isArray(names)) return names.includes(value)
|
|
711
|
+
if (names instanceof Set) return names.has(value)
|
|
712
|
+
return Object.prototype.hasOwnProperty.call(names, value)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function formatStringValue(ctx: Ctx, prop: string, value: string): FormattedValue | null {
|
|
716
|
+
const category = getTokenCategory(prop)
|
|
717
|
+
if (
|
|
718
|
+
prop !== 'fontWeight' &&
|
|
719
|
+
category &&
|
|
720
|
+
hasConfiguredName(ctx.grammarConfig.tokenNames?.[category], value)
|
|
721
|
+
) {
|
|
722
|
+
if (category === 'color') {
|
|
723
|
+
const mapped = getV6ThemeName(value)
|
|
724
|
+
return mapped ? { value: mapped, valueKind: 'token' } : null
|
|
725
|
+
}
|
|
726
|
+
return { value, valueKind: 'token' }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// named font weight (fontWeight only). an unknown weight ("450") → retain (not font-[450]).
|
|
730
|
+
if (prop === 'fontWeight') {
|
|
731
|
+
return fontWeightNames[value] ? { value, valueKind: 'enum' } : null
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (prop === 'zIndex' && /^\d+$/.test(value)) {
|
|
735
|
+
return { value, valueKind: 'convenience' }
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// a NUMERIC-LOOKING STRING literal ("10", "0.5") is NOT a number — reinterpreting it as the
|
|
739
|
+
// Tailwind scale or a token would diverge from the source string (which tamagui keeps verbatim,
|
|
740
|
+
// quirks and all). it's also not a valid unit-bearing CSS value → RETAIN. (fontWeight handled
|
|
741
|
+
// above; percentages/units carry a suffix and are handled below.)
|
|
742
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return null
|
|
743
|
+
|
|
744
|
+
// percentages: NAMED fraction ONLY when string-exact (50/25/75/100); an inexact value like
|
|
745
|
+
// 33.333% must stay the EXACT arbitrary (1/3 resolves to 33.3333…% ≠ source).
|
|
746
|
+
if (value.endsWith('%')) {
|
|
747
|
+
const exact: Record<string, string> = {
|
|
748
|
+
'100%': 'full',
|
|
749
|
+
'50%': '1/2',
|
|
750
|
+
'25%': '1/4',
|
|
751
|
+
'75%': '3/4',
|
|
752
|
+
}
|
|
753
|
+
if (getTokenCategory(prop) === 'size' && exact[value]) {
|
|
754
|
+
return { value: exact[value], valueKind: 'convenience' }
|
|
755
|
+
}
|
|
756
|
+
return { value, valueKind: 'arbitrary' }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (value === 'auto' && getTokenCategory(prop) === 'size') {
|
|
760
|
+
return { value, valueKind: 'convenience' }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// raw fontFamily ("Inter-Black", "My_Font") is NOT a token → emit an ARBITRARY so the parser
|
|
764
|
+
// treats it as a literal family (font-<name> would be read as the <name> font token).
|
|
765
|
+
if (prop === 'fontFamily') return { value, valueKind: 'arbitrary' }
|
|
766
|
+
|
|
767
|
+
// arbitrary CSS values → bracket so the Tailwind frontend's `[..]` parser resolves them
|
|
768
|
+
if (
|
|
769
|
+
value.includes('(') ||
|
|
770
|
+
value.includes(' ') ||
|
|
771
|
+
value.includes('_') ||
|
|
772
|
+
value.startsWith('#') ||
|
|
773
|
+
value.startsWith('-') ||
|
|
774
|
+
/^[\d.]+[a-z%]/i.test(value)
|
|
775
|
+
) {
|
|
776
|
+
return { value, valueKind: 'arbitrary' }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Plain strings are raw values. Brackets distinguish them from configured token names.
|
|
780
|
+
return { value, valueKind: 'arbitrary' }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// props whose numeric value is a PX LENGTH — emit [Npx]; the runtime parser coerces [Npx] to a
|
|
784
|
+
// NUMBER (React Native requires numbers; web re-adds px). border widths are handled separately
|
|
785
|
+
// (they have Tailwind integer utilities). this is an explicit inventory — a numeric prop NOT
|
|
786
|
+
// classified here is NOT converted (returns null → retained), never blindly defaulted to px.
|
|
787
|
+
const pxLengthProps = new Set([
|
|
788
|
+
'width',
|
|
789
|
+
'height',
|
|
790
|
+
'minWidth',
|
|
791
|
+
'maxWidth',
|
|
792
|
+
'minHeight',
|
|
793
|
+
'maxHeight',
|
|
794
|
+
'flexBasis',
|
|
795
|
+
'padding',
|
|
796
|
+
'paddingTop',
|
|
797
|
+
'paddingRight',
|
|
798
|
+
'paddingBottom',
|
|
799
|
+
'paddingLeft',
|
|
800
|
+
'paddingHorizontal',
|
|
801
|
+
'paddingVertical',
|
|
802
|
+
'margin',
|
|
803
|
+
'marginTop',
|
|
804
|
+
'marginRight',
|
|
805
|
+
'marginBottom',
|
|
806
|
+
'marginLeft',
|
|
807
|
+
'marginHorizontal',
|
|
808
|
+
'marginVertical',
|
|
809
|
+
'gap',
|
|
810
|
+
'rowGap',
|
|
811
|
+
'columnGap',
|
|
812
|
+
'top',
|
|
813
|
+
'right',
|
|
814
|
+
'bottom',
|
|
815
|
+
'left',
|
|
816
|
+
'inset',
|
|
817
|
+
'borderRadius',
|
|
818
|
+
'borderTopLeftRadius',
|
|
819
|
+
'borderTopRightRadius',
|
|
820
|
+
'borderBottomLeftRadius',
|
|
821
|
+
'borderBottomRightRadius',
|
|
822
|
+
'fontSize',
|
|
823
|
+
'letterSpacing',
|
|
824
|
+
'lineHeight',
|
|
825
|
+
])
|
|
826
|
+
|
|
827
|
+
function formatNumericValue(prop: string, value: number): FormattedValue | null {
|
|
828
|
+
// opacity: use the named percentage utility ONLY when value*100 is EXACTLY an integer
|
|
829
|
+
// (0.5 → opacity-50); otherwise emit an arbitrary unitless value (0.333 → opacity-[0.333])
|
|
830
|
+
// so the resolved opacity is EXACT, never rounded/lossy.
|
|
831
|
+
if (prop === 'opacity') {
|
|
832
|
+
const pct = value * 100
|
|
833
|
+
return Number.isInteger(pct)
|
|
834
|
+
? { value: String(pct), valueKind: 'convenience' }
|
|
835
|
+
: { value: String(value), valueKind: 'arbitrary' }
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// UNITLESS number → [N] (no px): number on both platforms
|
|
839
|
+
if (prop === 'aspectRatio') return { value: String(value), valueKind: 'arbitrary' }
|
|
840
|
+
if (prop === 'scale' || prop === 'scaleX' || prop === 'scaleY') {
|
|
841
|
+
if (value === 0) return { value: '0', valueKind: 'convenience' }
|
|
842
|
+
if (value === 1) return { value: '100', valueKind: 'convenience' }
|
|
843
|
+
return { value: String(value), valueKind: 'arbitrary' }
|
|
844
|
+
}
|
|
845
|
+
if (prop === 'flex') {
|
|
846
|
+
return value === 1
|
|
847
|
+
? { value: '1', valueKind: 'convenience' }
|
|
848
|
+
: { value: String(value), valueKind: 'arbitrary' }
|
|
849
|
+
}
|
|
850
|
+
if (prop === 'flexGrow' || prop === 'flexShrink') {
|
|
851
|
+
return { value: String(value), valueKind: 'arbitrary' }
|
|
852
|
+
}
|
|
853
|
+
if (prop === 'zIndex') {
|
|
854
|
+
return Number.isInteger(value) && value >= 0
|
|
855
|
+
? { value: String(value), valueKind: 'convenience' }
|
|
856
|
+
: { value: String(value), valueKind: 'arbitrary' }
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// NAMED weight: numeric 700 → the named-weight class; unknown weight → retain (null)
|
|
860
|
+
if (prop === 'fontWeight') {
|
|
861
|
+
return fontWeightNames[String(value)]
|
|
862
|
+
? { value: String(value), valueKind: 'enum' }
|
|
863
|
+
: null
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// rotate: a NUMERIC rotate has no unit — inventing "deg" diverges from the source (tamagui
|
|
867
|
+
// rotate is a unit-bearing string). RETAIN numeric rotate; only rotate="10deg" (a string that
|
|
868
|
+
// already carries the unit) round-trips via formatStringValue's arbitrary path.
|
|
869
|
+
if (prop === 'rotate') return null
|
|
870
|
+
|
|
871
|
+
// translate x/y → px length
|
|
872
|
+
if (prop === 'x' || prop === 'y') {
|
|
873
|
+
return { value: `${value}px`, valueKind: 'arbitrary' }
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Raw border-width numbers are pixels. Bare border-N is a token name in class grammar, so raw
|
|
877
|
+
// values always use the arbitrary form.
|
|
878
|
+
if (prop.includes('Width') && prop.startsWith('border')) {
|
|
879
|
+
return { value: `${value}px`, valueKind: 'arbitrary' }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// PX LENGTH props → [Npx] (negative keeps the sign inside the brackets)
|
|
883
|
+
if (pxLengthProps.has(prop)) {
|
|
884
|
+
return { value: `${value}px`, valueKind: 'arbitrary' }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// unclassified numeric prop → do NOT guess a unit; retain the source prop
|
|
888
|
+
return null
|
|
889
|
+
}
|