@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.
@@ -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
+ }