@tamagui/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.
Files changed (77) hide show
  1. package/dist/cjs/candidate.cjs +385 -0
  2. package/dist/cjs/candidate.native.cjs +431 -0
  3. package/dist/cjs/candidate.native.js +433 -0
  4. package/dist/cjs/candidate.native.js.map +1 -0
  5. package/dist/cjs/frontend.cjs +80 -0
  6. package/dist/cjs/frontend.native.cjs +95 -0
  7. package/dist/cjs/frontend.native.js +97 -0
  8. package/dist/cjs/frontend.native.js.map +1 -0
  9. package/dist/cjs/index.cjs +39 -0
  10. package/dist/cjs/index.native.cjs +41 -0
  11. package/dist/cjs/index.native.js +43 -0
  12. package/dist/cjs/index.native.js.map +1 -0
  13. package/dist/cjs/styled.cjs +30 -0
  14. package/dist/cjs/styled.native.cjs +32 -0
  15. package/dist/cjs/styled.native.js +34 -0
  16. package/dist/cjs/styled.native.js.map +1 -0
  17. package/dist/cjs/types.cjs +17 -0
  18. package/dist/cjs/types.native.cjs +19 -0
  19. package/dist/cjs/types.native.js +21 -0
  20. package/dist/cjs/types.native.js.map +1 -0
  21. package/dist/cjs/vite/state.cjs +249 -0
  22. package/dist/cjs/vite/state.native.cjs +331 -0
  23. package/dist/cjs/vite/state.native.js +333 -0
  24. package/dist/cjs/vite/state.native.js.map +1 -0
  25. package/dist/cjs/vite.cjs +148 -0
  26. package/dist/cjs/vite.native.cjs +188 -0
  27. package/dist/cjs/vite.native.js +190 -0
  28. package/dist/cjs/vite.native.js.map +1 -0
  29. package/dist/esm/candidate.mjs +360 -0
  30. package/dist/esm/candidate.mjs.map +1 -0
  31. package/dist/esm/candidate.native.js +404 -0
  32. package/dist/esm/candidate.native.js.map +1 -0
  33. package/dist/esm/frontend.mjs +57 -0
  34. package/dist/esm/frontend.mjs.map +1 -0
  35. package/dist/esm/frontend.native.js +70 -0
  36. package/dist/esm/frontend.native.js.map +1 -0
  37. package/dist/esm/index.mjs +11 -0
  38. package/dist/esm/index.mjs.map +1 -0
  39. package/dist/esm/index.native.js +11 -0
  40. package/dist/esm/index.native.js.map +1 -0
  41. package/dist/esm/styled.mjs +10 -0
  42. package/dist/esm/styled.mjs.map +1 -0
  43. package/dist/esm/styled.native.js +10 -0
  44. package/dist/esm/styled.native.js.map +1 -0
  45. package/dist/esm/types.mjs +0 -0
  46. package/dist/esm/types.native.js +0 -0
  47. package/dist/esm/vite/state.mjs +215 -0
  48. package/dist/esm/vite/state.mjs.map +1 -0
  49. package/dist/esm/vite/state.native.js +295 -0
  50. package/dist/esm/vite/state.native.js.map +1 -0
  51. package/dist/esm/vite.mjs +116 -0
  52. package/dist/esm/vite.mjs.map +1 -0
  53. package/dist/esm/vite.native.js +154 -0
  54. package/dist/esm/vite.native.js.map +1 -0
  55. package/package.json +88 -5
  56. package/src/candidate.ts +527 -0
  57. package/src/frontend.ts +116 -0
  58. package/src/index.tsx +24 -0
  59. package/src/styled.tsx +52 -0
  60. package/src/types.ts +226 -0
  61. package/src/vite/state.ts +274 -0
  62. package/src/vite.ts +204 -0
  63. package/types/candidate.d.ts +23 -0
  64. package/types/candidate.d.ts.map +1 -0
  65. package/types/frontend.d.ts +20 -0
  66. package/types/frontend.d.ts.map +1 -0
  67. package/types/index.d.ts +8 -0
  68. package/types/index.d.ts.map +1 -0
  69. package/types/styled.d.ts +12 -0
  70. package/types/styled.d.ts.map +1 -0
  71. package/types/types.d.ts +158 -0
  72. package/types/types.d.ts.map +1 -0
  73. package/types/vite/state.d.ts +28 -0
  74. package/types/vite/state.d.ts.map +1 -0
  75. package/types/vite.d.ts +20 -0
  76. package/types/vite.d.ts.map +1 -0
  77. package/README.md +0 -1
@@ -0,0 +1,527 @@
1
+ import { isWeb } from '@tamagui/constants'
2
+ import {
3
+ STYLE_FRONTEND_PASSTHROUGH_PREFIX,
4
+ createFrontendProgram,
5
+ plainValueToPayload,
6
+ type StyleFrontendConfig,
7
+ } from '@tamagui/core/internal-runtime'
8
+ import {
9
+ borderSideSuffix,
10
+ classifyCandidate,
11
+ createGrammarConfigView,
12
+ decodeArbitrary,
13
+ getTokenCategory,
14
+ getSafeAreaEdge,
15
+ percentUtilityProps,
16
+ radiusCornerProps,
17
+ splitColorOpacitySuffix,
18
+ type GrammarConfigView,
19
+ type ParsedCandidate,
20
+ } from '@tamagui/style-grammar/runtime'
21
+
22
+ /**
23
+ * Class-string tokenization and candidate adaptation. This is the Tailwind
24
+ * frontend's private half of the pipeline: everything here is about how a
25
+ * candidate is SPELLED (brackets, slashes, fractions, keywords, negatives).
26
+ *
27
+ * The semantic parser itself stays in `@tamagui/style-grammar`, shared with the
28
+ * converter and build tooling, and everything downstream of the props this module
29
+ * produces — value programs, merging, lowering, evaluation — is core's shared
30
+ * renderer.
31
+ */
32
+
33
+ const styleGrammarConfigCache = new WeakMap<StyleFrontendConfig, GrammarConfigView>()
34
+
35
+ export function getStyleGrammarConfig(config: StyleFrontendConfig): GrammarConfigView {
36
+ const cached = styleGrammarConfigCache.get(config)
37
+ if (cached) return cached
38
+ const view = createGrammarConfigView(config)
39
+ styleGrammarConfigCache.set(config, view)
40
+ return view
41
+ }
42
+
43
+ export function isTokenValueProp(prop: string): boolean {
44
+ return getTokenCategory(prop) !== null
45
+ }
46
+
47
+ // CANONICAL arbitrary-value coercion. an arbitrary `[..]` whose inner is a UNITLESS number
48
+ // (z-[400], aspect-[1.5], leading-[1.25]) or a PX length (p-[18px], text-[14px], border-[0.5px])
49
+ // resolves to a NUMBER: React Native REQUIRES numbers for dimensional + typography props and
50
+ // silently DROPS "Npx"/"400" strings (Yoga parseCSSProperty<CSSNumber,CSSPercentage> rejects px;
51
+ // StyleSheetTypes types fontSize/lineHeight/letterSpacing as number). web accepts the number and
52
+ // re-adds px via CSS. anything carrying a real unit/function (%, rem, vh, deg, calc(), var(),
53
+ // #hex, colors) stays a STRING.
54
+ function arbitraryValue(inner: string): number | string {
55
+ const px = /^(-?\d*\.?\d+)px$/.exec(inner)
56
+ if (px) return Number(px[1])
57
+ if (/^-?\d*\.?\d+$/.test(inner)) return Number(inner)
58
+ return inner
59
+ }
60
+
61
+ // tailwind sizing keywords / fractions for width/height/min/max props.
62
+ // returns a CSS value, or null if `value` isn't a sizing keyword (falls through to normal parse).
63
+ function tailwindSizingValue(prop: string, value: string): string | null {
64
+ if (value === 'full') return '100%'
65
+ if (value === 'auto') return 'auto'
66
+ if (value === 'screen') return /[Hh]eight/.test(prop) ? '100vh' : '100vw'
67
+ if (value === 'min') return 'min-content'
68
+ if (value === 'max') return 'max-content'
69
+ if (value === 'fit') return 'fit-content'
70
+ const frac = /^(\d+)\/(\d+)$/.exec(value)
71
+ if (frac && Number(frac[2]) !== 0) {
72
+ return `${(Number(frac[1]) / Number(frac[2])) * 100}%`
73
+ }
74
+ return null
75
+ }
76
+
77
+ // unwrap a possibly-arbitrary value ([2px] → "2px") and coerce a bare number / px length to
78
+ // a NUMBER (2px → 2, 0.5 → 0.5) so borderWidth/radius match tamagui's numeric props; other
79
+ // units (1em, calc(…)) stay strings.
80
+ function borderDimValue(raw: string): number | string {
81
+ let inner = raw
82
+ if (raw.length > 1 && raw[0] === '[' && raw[raw.length - 1] === ']') {
83
+ inner = decodeArbitrary(raw.slice(1, -1))
84
+ }
85
+ const px = /^(-?\d*\.?\d+)px$/.exec(inner)
86
+ if (px) return Number.parseFloat(px[1])
87
+ if (/^-?\d*\.?\d+$/.test(inner)) return Number(inner)
88
+ return inner
89
+ }
90
+
91
+ /**
92
+ * Expand a registry-parsed directional border/radius into all affected props. The registry
93
+ * already selected the token category and width-vs-color meaning; this function only expands
94
+ * that semantic result across sides/corners.
95
+ */
96
+ function expandBorderCandidate(
97
+ parsed: ParsedCandidate,
98
+ value: any
99
+ ): Record<string, any> | null {
100
+ const prefix = parsed.prefix
101
+ if (!prefix) return null
102
+ if (prefix.startsWith('rounded-')) {
103
+ const props = radiusCornerProps[prefix.slice('rounded-'.length)]
104
+ if (!props || props.length === 1) return null
105
+ const out: Record<string, any> = {}
106
+ for (const p of props) out[p] = value
107
+ return out
108
+ }
109
+
110
+ const m = /^border-([trblxy])$/.exec(prefix)
111
+ if (!m) return null
112
+ const sides = borderSideSuffix[m[1]]
113
+ const suffix = parsed.entry?.prop.endsWith('Width') ? 'Width' : 'Color'
114
+ const out: Record<string, any> = {}
115
+ for (const side of sides) out[`border${side}${suffix}`] = value
116
+ return out
117
+ }
118
+
119
+ /**
120
+ * Adapt a registry-parsed candidate into the ordinary props shared rendering consumes.
121
+ * Examples:
122
+ * "hover:bg-blue5" → backgroundColor + a `hover:blue5` program
123
+ * "sm:p-4" → padding + an `sm:4` program
124
+ * "bg-[red]" → { key: "backgroundColor", value: "red" }
125
+ * "w-100" → { key: "width", value: 100 }
126
+ * "opacity-50" → { key: "opacity", value: 0.5 }
127
+ */
128
+ function tailwindClassToFlatProp(
129
+ parsed: ParsedCandidate
130
+ ): { key: string; value: any } | null {
131
+ if (parsed.kind !== 'dynamic' || !parsed.entry || parsed.rawValue === undefined) {
132
+ return null
133
+ }
134
+ const prop = parsed.entry.prop
135
+ const category = parsed.entry.tokenCategory
136
+ let value: any = parsed.rawValue
137
+
138
+ if (prop.endsWith('Width') && parsed.prefix?.startsWith('border')) {
139
+ if (parsed.valueKind === 'token') {
140
+ value = `${parsed.negative ? '-' : ''}${value}`
141
+ } else if (parsed.valueKind === 'arbitrary') {
142
+ value = borderDimValue(value)
143
+ } else {
144
+ return null
145
+ }
146
+ return { key: prop, value }
147
+ }
148
+
149
+ if (prop === 'fontFamily') {
150
+ let famValue: string
151
+ if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') {
152
+ famValue = decodeArbitrary(value.slice(1, -1))
153
+ } else {
154
+ const generic: Record<string, string> = {
155
+ sans: 'sans-serif',
156
+ serif: 'serif',
157
+ mono: 'monospace',
158
+ }
159
+ famValue = parsed.valueKind === 'token' ? value : generic[value] || value
160
+ }
161
+ return {
162
+ key: 'fontFamily',
163
+ value: famValue,
164
+ }
165
+ }
166
+
167
+ if (prop === 'fontSize') {
168
+ let fsValue: any
169
+ if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') {
170
+ // fontSize is number-only on native: text-[14px] → 14 (arbitraryValue drops px)
171
+ fsValue = arbitraryValue(decodeArbitrary(value.slice(1, -1)))
172
+ } else {
173
+ fsValue = value
174
+ }
175
+ return {
176
+ key: 'fontSize',
177
+ value: fsValue,
178
+ }
179
+ }
180
+
181
+ if (prop === 'lineHeight') {
182
+ let lhValue: any
183
+ if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') {
184
+ const inner = decodeArbitrary(value.slice(1, -1))
185
+ // px length → NUMBER (native-valid: leading-[20px] → 20). a UNITLESS value is a web
186
+ // lineHeight MULTIPLIER and MUST stay a string (a number would be px-ified to "1.25px"
187
+ // on web, breaking the multiplier). RN has no unitless multiplier, so this is web-only.
188
+ lhValue = /^-?\d*\.?\d+px$/.test(inner) ? Number.parseFloat(inner) : inner
189
+ } else {
190
+ lhValue = value
191
+ }
192
+ return {
193
+ key: 'lineHeight',
194
+ value: lhValue,
195
+ }
196
+ }
197
+
198
+ if (prop === 'letterSpacing') {
199
+ let lsValue: any
200
+ if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') {
201
+ lsValue = arbitraryValue(decodeArbitrary(value.slice(1, -1)))
202
+ } else {
203
+ lsValue = value
204
+ }
205
+ return {
206
+ key: 'letterSpacing',
207
+ value: lsValue,
208
+ }
209
+ }
210
+
211
+ if (prop === 'boxShadow' && value[0] === '[' && value[value.length - 1] === ']') {
212
+ return {
213
+ key: 'boxShadow',
214
+ value: decodeArbitrary(value.slice(1, -1)),
215
+ }
216
+ }
217
+
218
+ // arbitrary values: p-[4px], w-[100px], rounded-[8px], min-h-[100vh], rotate-[-8deg],
219
+ // h-[calc(100%-2px)], bg-[var(--color5)], bg-[#fff]. use the bracketed value directly as
220
+ // CSS — no scaling/token resolution. tailwind encodes spaces inside [] as underscores.
221
+ if (value.length > 2 && value[0] === '[' && value[value.length - 1] === ']') {
222
+ const inner = decodeArbitrary(value.slice(1, -1))
223
+ if (inner === '') return null
224
+ // px-length + unitless arbitraries become NUMBERS (native requires numbers, drops "Npx"
225
+ // strings); unit/function values stay strings. one canonical rule (arbitraryValue).
226
+ return { key: prop, value: arbitraryValue(inner) }
227
+ }
228
+
229
+ // tailwind sizing keywords / fractions (w-full → 100%, w-1/2 → 50%, w-auto, w-screen).
230
+ // handled before isValidTailwindValue since fractions/keywords aren't plain CSS values. An
231
+ // exact configured size token with the same spelling stays a token.
232
+ if (category === 'size' && parsed.valueKind !== 'token') {
233
+ const sized = tailwindSizingValue(prop, value)
234
+ if (sized != null) {
235
+ return { key: prop, value: sized }
236
+ }
237
+ }
238
+
239
+ // color opacity modifier: bg-blue-500/50 → split value into base + /N suffix,
240
+ // re-attached after token resolution so the shared color resolver applies it via color-mix
241
+ // (web) / rgba (native). only for color props; for non-color props a "/" in the
242
+ // value is left intact (e.g. fraction sizing handled above).
243
+ let opacitySuffix = ''
244
+ if (category === 'color' && typeof value === 'string') {
245
+ const suffix = splitColorOpacitySuffix(value)
246
+ if (suffix.kind === 'invalid') {
247
+ return { key: prop, value }
248
+ }
249
+ if (suffix.kind === 'valid') {
250
+ opacitySuffix = value.slice(suffix.name.length)
251
+ value = suffix.name
252
+ }
253
+ }
254
+
255
+ // handle special value patterns
256
+ if (percentUtilityProps.has(prop) && /^\d+$/.test(value)) {
257
+ // tailwind percentage utilities: opacity-50 → 0.5, scale-95 → 0.95, scale-100 → 1
258
+ value = Number(value) / 100
259
+ } else if (/^\d+(\.\d+)?$/.test(value)) {
260
+ if (category === 'zIndex' && parsed.valueKind === 'convenience') {
261
+ value = Number(value)
262
+ } else if (category) {
263
+ value = `${parsed.negative ? '-' : ''}${value}`
264
+ } else {
265
+ value = Number(value)
266
+ }
267
+ } else if (typeof value === 'string') {
268
+ if (
269
+ category &&
270
+ !(
271
+ (category === 'space' || category === 'size' || category === 'radius') &&
272
+ getSafeAreaEdge(value)
273
+ )
274
+ ) {
275
+ value = `${parsed.negative ? '-' : ''}${value}`
276
+ }
277
+ }
278
+
279
+ // re-attach the color opacity suffix (bg-blue-500/50). the shared flat-value
280
+ // resolver parses the trailing /N after its property-scoped name lookup.
281
+ if (opacitySuffix && typeof value === 'string') {
282
+ value = `${value}${opacitySuffix}`
283
+ }
284
+
285
+ if (parsed.negative && !category) {
286
+ if (typeof value === 'number') value = -value
287
+ else if (typeof value === 'string' && value[0] !== '-') value = `-${value}`
288
+ }
289
+
290
+ return { key: prop, value }
291
+ }
292
+
293
+ // Parsing a Tailwind candidate depends only on the class string and the grammar
294
+ // config, never on the surrounding props, so the decision is cached per class.
295
+ // Without this the full parse (split + classify + resolve + border expansion) ran
296
+ // on every render of every component carrying a className — measured at ~18µs per
297
+ // call for a 4-class string, a 1.53x tax over writing the same styles as props.
298
+ // The plan is a flat [key, value][] applied in order, so the authored ordering
299
+ // between classes and ordinary props is unchanged.
300
+ //
301
+ // null = web-only candidate dropped on native. 'raw' = not claimed by the
302
+ // grammar, caller preserves the class string. An array (which may legitimately be
303
+ // empty) = claimed, apply these entries.
304
+ type TailwindPlanEntry = [key: string | null, value: any]
305
+ type TailwindClassPlan = TailwindPlanEntry[] | null | 'raw'
306
+ type TailwindParentPlan = {
307
+ entries: TailwindPlanEntry[]
308
+ preserveRawClass: boolean
309
+ }
310
+
311
+ type CachedTailwindClassPlan = TailwindClassPlan | TailwindParentPlan
312
+
313
+ const classPlanCache = new WeakMap<object, Map<string, CachedTailwindClassPlan>>()
314
+
315
+ function getClassPlanCache(grammarConfig: object) {
316
+ let cache = classPlanCache.get(grammarConfig)
317
+ if (!cache) {
318
+ cache = new Map()
319
+ classPlanCache.set(grammarConfig, cache)
320
+ }
321
+ return cache
322
+ }
323
+
324
+ function createPlanEntry(
325
+ property: string,
326
+ value: unknown,
327
+ modifiers: readonly string[]
328
+ ): TailwindPlanEntry | null {
329
+ if (modifiers.length === 0) return [property, value]
330
+ const payload = plainValueToPayload(value, property)
331
+ if (payload === null) return null
332
+ return [
333
+ null,
334
+ createFrontendProgram(property, {
335
+ base: null,
336
+ clauses: [{ modifiers, payload }],
337
+ }),
338
+ ]
339
+ }
340
+
341
+ function computeClassPlan(
342
+ cls: string,
343
+ grammarConfig: GrammarConfigView
344
+ ): CachedTailwindClassPlan {
345
+ const groupMarker = /^group(?:\/([A-Za-z0-9_-]+))?$/.exec(cls)
346
+ if (groupMarker) {
347
+ return {
348
+ entries: [['group', groupMarker[1] || true]],
349
+ preserveRawClass: isWeb,
350
+ }
351
+ }
352
+
353
+ const containerMarker = /^@container(-size)?(?:\/([A-Za-z0-9_-]+))?$/.exec(cls)
354
+ if (containerMarker) {
355
+ const isSize = containerMarker[1] !== undefined
356
+ const name = containerMarker[2]
357
+ const entries: TailwindPlanEntry[] = []
358
+ if (name) entries.push(['containerName', name])
359
+ if (isSize) {
360
+ entries.push(['containerType', 'size'])
361
+ } else if (name) {
362
+ entries.push(['containerType', 'inline-size'])
363
+ } else {
364
+ entries.push(['container', true])
365
+ }
366
+ return { entries, preserveRawClass: isWeb }
367
+ }
368
+
369
+ const classification = classifyCandidate(cls, grammarConfig)
370
+ if (classification.kind === 'passthrough') {
371
+ return isWeb ? 'raw' : null
372
+ }
373
+ const parsed = classification.parsed
374
+ // named utilities first (flex-row, flex-1, hidden, …) — whole class → fixed prop(s).
375
+ // these may emit multiple props and may have no dash, so handle before the generic parse.
376
+ const util = parsed.kind === 'utility' ? parsed.properties : null
377
+ if (util) {
378
+ const entries: TailwindPlanEntry[] = []
379
+ for (const p in util) {
380
+ const entry = createPlanEntry(p, util[p], parsed.modifiers)
381
+ if (!entry) return 'raw'
382
+ entries.push(entry)
383
+ }
384
+ return entries
385
+ }
386
+ // Resolve only after the registry has claimed the candidate. Directional border/radius
387
+ // expansion below consumes that parsed decision instead of re-parsing width vs color.
388
+ const flatProp = tailwindClassToFlatProp(parsed)
389
+ if (flatProp) {
390
+ const expanded = expandBorderCandidate(parsed, flatProp.value)
391
+ if (expanded) {
392
+ const entries: TailwindPlanEntry[] = []
393
+ for (const p in expanded) {
394
+ const entry = createPlanEntry(p, expanded[p], parsed.modifiers)
395
+ if (!entry) return 'raw'
396
+ entries.push(entry)
397
+ }
398
+ return entries
399
+ }
400
+ const entry = createPlanEntry(flatProp.key, flatProp.value, parsed.modifiers)
401
+ return entry ? [entry] : 'raw'
402
+ }
403
+ // not claimed: caller preserves the raw class
404
+ return 'raw'
405
+ }
406
+
407
+ const warnedNativePassthroughCandidates = new Set<string>()
408
+
409
+ /**
410
+ * Append a contribution at the end of the forward pass.
411
+ *
412
+ * Plain re-assignment keeps a key's FIRST insertion position, so a restated
413
+ * shorthand would stay behind a longhand authored between the two occurrences:
414
+ * `p-4 px-2 p-6` has to resolve `paddingLeft` from `p-6`, and `pt-2 p-4 pt-8` has to
415
+ * resolve `paddingTop` from `pt-8`. Deleting the key first moves it to the end,
416
+ * which is the authored order the shared per-longhand merge reads.
417
+ */
418
+ export function setInAuthoredOrder(
419
+ target: Record<string, any>,
420
+ key: string,
421
+ value: any
422
+ ): void {
423
+ if (key in target) delete target[key]
424
+ target[key] = value
425
+ }
426
+
427
+ /**
428
+ * Tokenize a className into ordinary props and internal value-program contributions,
429
+ * once per class per config.
430
+ * User-defined tokens drive resolution; Tailwind's color/spacing scales are never
431
+ * hardcoded. Classes the grammar does not claim stay in `className` verbatim, in
432
+ * author order, so official Tailwind CSS still applies them on web.
433
+ */
434
+ export function preprocessTailwindClassName(
435
+ props: Record<string, any>,
436
+ config: StyleFrontendConfig,
437
+ preservePassthroughPosition = false
438
+ ): Record<string, any> {
439
+ const className = props.className
440
+ if (!className || typeof className !== 'string') {
441
+ return props
442
+ }
443
+
444
+ const classes = className.split(/\s+/).filter(Boolean)
445
+ const result: Record<string, any> = {}
446
+ const grammarConfig = getStyleGrammarConfig(config)
447
+ const plans = getClassPlanCache(grammarConfig)
448
+ const classPlans = classes.map((cls) => {
449
+ let plan = plans.get(cls)
450
+ if (plan === undefined) {
451
+ plan = computeClassPlan(cls, grammarConfig)
452
+ plans.set(cls, plan)
453
+ }
454
+ return plan
455
+ })
456
+ const hasOwnedStyleCandidate =
457
+ preservePassthroughPosition && classPlans.some((plan) => Array.isArray(plan))
458
+ let frontendProgramIndex = 0
459
+ let passthroughIndex = 0
460
+ const regularClasses: string[] = []
461
+
462
+ const preserveRawClass = (cls: string) => {
463
+ if (hasOwnedStyleCandidate) {
464
+ result[`${STYLE_FRONTEND_PASSTHROUGH_PREFIX}${passthroughIndex++}`] = cls
465
+ } else {
466
+ regularClasses.push(cls)
467
+ }
468
+ }
469
+
470
+ const applyEntry = ([key, value]: TailwindPlanEntry) => {
471
+ if (key === null) {
472
+ result[`__tamagui_frontend_program_${frontendProgramIndex++}`] = value
473
+ } else {
474
+ setInAuthoredOrder(result, key, value)
475
+ }
476
+ }
477
+
478
+ // Expand the className exactly where it was authored. Claimed classes and
479
+ // ordinary props therefore share one forward pass: whichever contribution is
480
+ // encountered later wins. Classes within the string are likewise applied in
481
+ // their own left-to-right order.
482
+ for (const key in props) {
483
+ if (key !== 'className') {
484
+ setInAuthoredOrder(result, key, props[key])
485
+ continue
486
+ }
487
+ for (let classIndex = 0; classIndex < classes.length; classIndex++) {
488
+ const cls = classes[classIndex]
489
+ const plan = classPlans[classIndex]
490
+ if (plan === null) {
491
+ // web-only candidate on native: dropped, warned once
492
+ if (
493
+ process.env.NODE_ENV !== 'production' &&
494
+ !warnedNativePassthroughCandidates.has(cls)
495
+ ) {
496
+ warnedNativePassthroughCandidates.add(cls)
497
+ console.warn(
498
+ `[tamagui] Tailwind candidate "${cls}" is web-only and was dropped on native. Use a Tamagui grammar candidate or a native style prop for cross-platform output.`
499
+ )
500
+ }
501
+ continue
502
+ }
503
+ if (plan === 'raw') {
504
+ // not claimed by the grammar: preserve the class as-is
505
+ preserveRawClass(cls)
506
+ continue
507
+ }
508
+ if (!Array.isArray(plan)) {
509
+ if (plan.preserveRawClass) {
510
+ preserveRawClass(cls)
511
+ }
512
+ for (let i = 0; i < plan.entries.length; i++) {
513
+ applyEntry(plan.entries[i])
514
+ }
515
+ continue
516
+ }
517
+ for (let i = 0; i < plan.length; i++) {
518
+ applyEntry(plan[i])
519
+ }
520
+ }
521
+ if (regularClasses.length > 0) {
522
+ result.className = regularClasses.join(' ')
523
+ }
524
+ }
525
+
526
+ return result
527
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ STYLE_FRONTEND_PREPROCESSED,
3
+ type FrontendStaticConfig,
4
+ type StyleFrontend,
5
+ type StyleFrontendConfig,
6
+ } from '@tamagui/core/internal-runtime'
7
+ import { preprocessTailwindClassName } from './candidate'
8
+
9
+ /**
10
+ * The Tailwind frontend descriptor.
11
+ *
12
+ * `preprocessProps` is the single Tailwind pass: it tokenizes `className` once and
13
+ * emits ordinary props plus internal program contributions in authored order.
14
+ * Everything after this point — value programs,
15
+ * per-longhand forward merging, web lowering, native evaluation — is shared.
16
+ *
17
+ * Owned candidates are never string-merged: each contributes at its authored
18
+ * position and the shared resolver's last-contribution-wins rule decides. That is
19
+ * why `tailwind-merge` is not a dependency here.
20
+ */
21
+
22
+ /**
23
+ * Parse a static class string (a `styled()` base, a string variant value, a string
24
+ * compound-variant style) into ordinary style props.
25
+ */
26
+ export function parseStaticStyle(
27
+ input: string,
28
+ config: StyleFrontendConfig
29
+ ): Record<string, any> {
30
+ return preprocessTailwindClassName({ className: input }, config)
31
+ }
32
+
33
+ const normalizedStaticConfigCache = new WeakMap<
34
+ FrontendStaticConfig,
35
+ WeakMap<StyleFrontendConfig, FrontendStaticConfig>
36
+ >()
37
+ const normalizedStaticConfigs = new WeakSet<FrontendStaticConfig>()
38
+
39
+ function normalizeTailwindStaticConfig<Config extends FrontendStaticConfig>(
40
+ staticConfig: Config,
41
+ config: StyleFrontendConfig
42
+ ): Config {
43
+ if (normalizedStaticConfigs.has(staticConfig)) {
44
+ return staticConfig
45
+ }
46
+ let configCache = normalizedStaticConfigCache.get(staticConfig)
47
+ const cached = configCache?.get(config)
48
+ if (cached) return cached as Config
49
+
50
+ let variants = staticConfig.variants
51
+ if (variants) {
52
+ variants = Object.fromEntries(
53
+ Object.entries(variants).map(([variantName, definition]) => [
54
+ variantName,
55
+ typeof definition === 'object' && definition
56
+ ? Object.fromEntries(
57
+ Object.entries(definition).map(([matcher, value]) => [
58
+ matcher,
59
+ typeof value === 'string' ? parseStaticStyle(value, config) : value,
60
+ ])
61
+ )
62
+ : definition,
63
+ ])
64
+ )
65
+ }
66
+
67
+ const compoundVariants = staticConfig.compoundVariants?.map((compoundVariant) => ({
68
+ ...compoundVariant,
69
+ style:
70
+ typeof compoundVariant.style === 'string'
71
+ ? parseStaticStyle(compoundVariant.style, config)
72
+ : compoundVariant.style,
73
+ }))
74
+
75
+ // A class base is the one class string with no authored position, so its unclaimed
76
+ // classes cannot ride the forward pass the way a call-site className does. Partition
77
+ // them out: `baseStyle` holds styles only, and the raw remainder goes to
78
+ // `passthroughClassName` for the renderer to prepend.
79
+ let baseStyle = staticConfig.baseStyle
80
+ let passthroughClassName = staticConfig.passthroughClassName
81
+ if (staticConfig.baseClassName) {
82
+ const { className, ...styles } = parseStaticStyle(staticConfig.baseClassName, config)
83
+ baseStyle = styles
84
+ passthroughClassName = className
85
+ }
86
+
87
+ const normalized = {
88
+ ...staticConfig,
89
+ baseStyle,
90
+ passthroughClassName,
91
+ variants,
92
+ compoundVariants,
93
+ } as Config
94
+ normalizedStaticConfigs.add(normalized)
95
+ configCache ||= new WeakMap()
96
+ configCache.set(config, normalized)
97
+ normalizedStaticConfigCache.set(staticConfig, configCache)
98
+ return normalized
99
+ }
100
+
101
+ export const tailwindStyleFrontend: StyleFrontend = {
102
+ preprocessProps(props, config) {
103
+ const transformed =
104
+ typeof props.className === 'string'
105
+ ? preprocessTailwindClassName(props, config, true)
106
+ : props
107
+ // only a fresh object gets the marker; marking the caller's props would leak the
108
+ // symbol onto props the frontend never actually rewrote
109
+ if (transformed !== props) {
110
+ ;(transformed as any)[STYLE_FRONTEND_PREPROCESSED] = true
111
+ }
112
+ return transformed
113
+ },
114
+
115
+ normalizeStaticConfig: normalizeTailwindStaticConfig,
116
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tailwind authoring for Tamagui.
3
+ *
4
+ * Components here render through the same runtime as `@tamagui/core` — same
5
+ * config, tokens, themes, media, events, refs, accessibility, animations, and
6
+ * normalized style output. Only the authoring syntax differs, and it is selected
7
+ * by which package a component was imported from, never by a global setting.
8
+ *
9
+ * This entry is runtime-only. The official Tailwind scanner and build integration
10
+ * live behind `@tamagui/tailwind/vite` and are never reachable from here.
11
+ */
12
+ import { createFrontendViews } from '@tamagui/core/internal-runtime'
13
+ import { tailwindStyleFrontend } from './frontend'
14
+ import type { TailwindText, TailwindView } from './types'
15
+
16
+ const frontendViews = createFrontendViews(tailwindStyleFrontend)
17
+
18
+ export const View = frontendViews.View as unknown as TailwindView
19
+ export const Text = frontendViews.Text as unknown as TailwindText
20
+
21
+ export { styled } from './styled'
22
+ export { parseStaticStyle, tailwindStyleFrontend } from './frontend'
23
+ export { preprocessTailwindClassName } from './candidate'
24
+ export type * from './types'