@woodsportal/hubspot-kit 5.0.0-beta.0 → 5.0.0-beta.2

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 (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +2 -2
  3. package/scripts/tailwind/prefix-plugin.js +131 -182
  4. package/scripts/tailwind/prefix-plugin.test.mjs +110 -0
  5. package/scripts/tailwind/tailwind-content.js +29 -28
  6. package/scripts/vite/create-dev-server-config.mjs +14 -3
  7. package/scripts/vite/portal-build-shared.js +5 -3
  8. package/src/dev-module-config/__tests__/build-api-routes-custom-menu.test.ts +32 -0
  9. package/src/dev-module-config/__tests__/custom-menu.test.ts +77 -0
  10. package/src/dev-module-config/__tests__/drill-down-panels.test.ts +65 -0
  11. package/src/dev-module-config/__tests__/fixtures/golden-module.fixture.ts +2 -0
  12. package/src/dev-module-config/__tests__/flatten-visible-fields.test.ts +34 -1
  13. package/src/dev-module-config/__tests__/module-config-transform.test.ts +27 -0
  14. package/src/dev-module-config/runtime/module-config-runtime.ts +3 -1
  15. package/src/dev-module-config/schema/custom-menu.ts +124 -0
  16. package/src/dev-module-config/schema/validate-module-config.ts +2 -0
  17. package/src/dev-module-config/transform/build-hubspot-data.ts +14 -0
  18. package/src/dev-module-config/ui/FieldRenderer.tsx +2 -0
  19. package/src/dev-module-config/ui/ModuleConfigAnimatedPresence.tsx +4 -2
  20. package/src/dev-module-config/ui/ModuleConfigShell.tsx +16 -4
  21. package/src/dev-module-config/ui/editors/MenuOptionsEditor.tsx +34 -0
  22. package/src/dev-module-config/ui/hubspot/HsOccurrenceGroupEditor.tsx +16 -11
  23. package/src/dev-module-config/ui/navigation/ModuleConfigNavContext.tsx +27 -23
  24. package/src/dev-module-config/ui/navigation/drill-down-panels.ts +48 -0
  25. package/src/routing/build-api-routes.ts +45 -15
  26. package/src/routing/custom-menu-path.ts +14 -0
  27. package/templates/module-hybrid-cdn/src/assets/css/main.prod.css +0 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### CSS prefix
6
+ - **Auto-prefix** — class string bindings ending in `_CLASS` (e.g. `CARDS_GRID_CLASS`) are prefixed the same as `*DynamicClassName*`
7
+ - **Auto-prefix** — JSX props ending in `ClassName` (`containerClassName`, `pageLinkClassName`, …) and helpers ending in `Class` (`paginationItemBaseClass`, `navBtnClass`) are prefixed
8
+
3
9
  ## [1.0.44] - 2026-08-06
4
10
 
5
11
  ### Portal auth scaffold
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@woodsportal/hubspot-kit",
3
- "version": "5.0.0-beta.0",
3
+ "version": "5.0.0-beta.2",
4
4
  "packageManager": "pnpm@10.12.1",
5
5
  "description": "WoodsCLI — HubSpot CMS dev kit by WoodsPortal. wp CLI, Vite presets, module-config overlay, and theme/module build pipelines.",
6
6
  "type": "module",
@@ -52,7 +52,7 @@
52
52
  "build": "tsup",
53
53
  "dev": "tsup --watch",
54
54
  "typecheck": "tsc --noEmit",
55
- "test": "node --import tsx --test 'src/lib/**/*.test.ts' 'src/cli/**/*.test.ts' 'src/schema/**/*.test.ts' 'src/vite/**/*.test.ts' 'scripts/portal-build-id.test.mjs' 'scripts/resolve-consumer-dep.test.mjs' 'scripts/wp-cli-smoke.test.mjs' 'scripts/vite/ensure-dev-kit-shims.test.mjs' 'scripts/vite/consumer-react-aliases.test.mjs' 'scripts/vite/resolve-from-project-root.test.mjs' && vitest run src/dev-module-config/__tests__/normalize-repeater-items.test.ts src/dev-module-config/__tests__/module-config-transform.test.ts src/dev-module-config/__tests__/flatten-visible-fields.test.ts",
55
+ "test": "node --import tsx --test 'src/lib/**/*.test.ts' 'src/cli/**/*.test.ts' 'src/schema/**/*.test.ts' 'src/vite/**/*.test.ts' 'scripts/portal-build-id.test.mjs' 'scripts/resolve-consumer-dep.test.mjs' 'scripts/wp-cli-smoke.test.mjs' 'scripts/vite/ensure-dev-kit-shims.test.mjs' 'scripts/vite/consumer-react-aliases.test.mjs' 'scripts/vite/resolve-from-project-root.test.mjs' 'scripts/tailwind/prefix-plugin.test.mjs' && vitest run src/dev-module-config/__tests__/normalize-repeater-items.test.ts src/dev-module-config/__tests__/module-config-transform.test.ts src/dev-module-config/__tests__/flatten-visible-fields.test.ts src/dev-module-config/__tests__/drill-down-panels.test.ts",
56
56
  "test:examples": "node examples/smoke/run-matrix.mjs --tier static",
57
57
  "test:dist-pack": "node --test scripts/dist-cli-pack-smoke.test.mjs",
58
58
  "lint": "eslint src",
@@ -7,15 +7,49 @@ import { prefix } from './prefix.env.js'
7
7
  const prefix1 = `${prefix}:`
8
8
  const prefix2 = `${prefix}\:`
9
9
 
10
+ /** Bindings rewritten even when not in `className=`: `*DynamicClassName*`, `_CLASS`, `*ClassName`, `*Class`. */
11
+ export function isClassBindingName(name) {
12
+ if (!name) return false
13
+ return (
14
+ name.includes('DynamicClassName') ||
15
+ name.endsWith('_CLASS') ||
16
+ /(?:ClassName|Class)$/.test(name)
17
+ )
18
+ }
19
+
20
+ /** JSX / object keys: `className`, `containerClassName`, `pageLinkClassName`, … */
21
+ export function isClassAttrName(name) {
22
+ if (!name) return false
23
+ return name === 'className' || name.endsWith('ClassName')
24
+ }
25
+
26
+ function jsxAttrName(attr) {
27
+ if (t.isJSXIdentifier(attr.name)) return attr.name.name
28
+ return ''
29
+ }
30
+
31
+ function objectKeyName(key) {
32
+ if (t.isIdentifier(key)) return key.name
33
+ if (t.isStringLiteral(key)) return key.value
34
+ return ''
35
+ }
36
+
37
+ /** Vite serve ids often include `?t=` / `?v=` — strip those before extension checks. */
38
+ function filePathFromId(id) {
39
+ return (id.split('?')[0] ?? id).replaceAll('\\', '/')
40
+ }
41
+
10
42
  export default function TailwindPrefixPlugin() {
11
43
  return {
12
44
  name: 'vite-tailwind-prefix',
13
45
  enforce: 'pre',
14
46
  transform(code, id) {
47
+ const filename = filePathFromId(id)
48
+
15
49
  // Handle CSS files: prefix custom class selectors safely
16
- if (id.endsWith('.css')) {
50
+ if (filename.endsWith('.css')) {
17
51
  // don't need to add prefix to override.style.css
18
- if (id.endsWith('override.style.css')) return code;
52
+ if (filename.endsWith('override.style.css')) return code;
19
53
 
20
54
 
21
55
  const lines = code.split('\n')
@@ -61,7 +95,7 @@ export default function TailwindPrefixPlugin() {
61
95
  }
62
96
 
63
97
 
64
- if (!id.endsWith('.tsx') && !id.endsWith('.ts')) return null
98
+ if (!filename.endsWith('.tsx') && !filename.endsWith('.ts')) return null
65
99
 
66
100
  try {
67
101
  const ast = parse(code, {
@@ -95,23 +129,84 @@ export default function TailwindPrefixPlugin() {
95
129
  .join(' ')
96
130
  }
97
131
 
98
- const prefixStringExpr = (expr) => {
132
+ const prefixExpr = (expr) => {
133
+ if (!expr) return expr
134
+
135
+ if (
136
+ t.isTSAsExpression(expr) ||
137
+ t.isTSTypeAssertion(expr) ||
138
+ t.isTSSatisfiesExpression(expr) ||
139
+ t.isTSNonNullExpression(expr) ||
140
+ t.isParenthesizedExpression(expr)
141
+ ) {
142
+ expr.expression = prefixExpr(expr.expression)
143
+ return expr
144
+ }
145
+
99
146
  if (t.isStringLiteral(expr)) {
100
147
  return t.stringLiteral(prefixClasses(expr.value))
101
148
  }
149
+
150
+ if (t.isTemplateLiteral(expr)) {
151
+ const quasis = expr.quasis.map((quasi, i) => {
152
+ let classes = prefixClasses(quasi.value.raw)
153
+ if (i < expr.expressions.length) classes += ' '
154
+ if (i > 0 && !classes.startsWith(' ')) classes = ' ' + classes
155
+ return t.templateElement({ raw: classes, cooked: classes })
156
+ })
157
+ const expressions = expr.expressions.map((inner) => prefixExpr(inner))
158
+ return t.templateLiteral(quasis, expressions)
159
+ }
160
+
102
161
  if (t.isConditionalExpression(expr)) {
103
- return t.conditionalExpression(
104
- expr.test,
105
- prefixStringExpr(expr.consequent),
106
- prefixStringExpr(expr.alternate),
107
- )
162
+ expr.consequent = prefixExpr(expr.consequent)
163
+ expr.alternate = prefixExpr(expr.alternate)
164
+ return expr
165
+ }
166
+
167
+ if (t.isLogicalExpression(expr)) {
168
+ if (t.isStringLiteral(expr.left) || t.isTemplateLiteral(expr.left)) {
169
+ expr.left = prefixExpr(expr.left)
170
+ }
171
+ expr.right = prefixExpr(expr.right)
172
+ return expr
173
+ }
174
+
175
+ if (t.isObjectExpression(expr)) {
176
+ expr.properties.forEach((prop) => {
177
+ if (!t.isObjectProperty(prop) || prop.computed) return
178
+ prop.value = prefixExpr(prop.value)
179
+ })
180
+ return expr
181
+ }
182
+
183
+ if (t.isArrayExpression(expr)) {
184
+ expr.elements = expr.elements.map((el) => {
185
+ if (!el || t.isSpreadElement(el)) return el
186
+ return prefixExpr(el)
187
+ })
188
+ return expr
108
189
  }
190
+
191
+ if (t.isCallExpression(expr)) {
192
+ expr.arguments = expr.arguments.map((arg) => {
193
+ if (t.isSpreadElement(arg)) return arg
194
+ return prefixExpr(arg)
195
+ })
196
+ return expr
197
+ }
198
+
199
+ if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
200
+ if (!t.isBlockStatement(expr.body)) {
201
+ expr.body = prefixExpr(expr.body)
202
+ }
203
+ return expr
204
+ }
205
+
109
206
  return expr
110
207
  }
111
208
 
112
209
  traverse.default(ast, {
113
- // ✅ Handle variables whose name includes "DynamicClassName"
114
-
115
210
  ReturnStatement(path) {
116
211
  const fn = path.getFunctionParent()
117
212
  if (!fn) return
@@ -127,186 +222,37 @@ export default function TailwindPrefixPlugin() {
127
222
  fnName = parent.id.name
128
223
  }
129
224
  }
130
- const isClassHelper =
131
- fnName.includes('DynamicClassName') ||
132
- /(?:ClassName|Class)$/.test(fnName)
133
- if (!isClassHelper) return
134
-
135
- if (t.isStringLiteral(path.node.argument)) {
136
- path.node.argument = t.stringLiteral(prefixClasses(path.node.argument.value))
137
- } else if (t.isConditionalExpression(path.node.argument)) {
138
- path.node.argument = prefixStringExpr(path.node.argument)
225
+ if (!isClassBindingName(fnName)) return
226
+ if (path.node.argument) {
227
+ path.node.argument = prefixExpr(path.node.argument)
139
228
  }
140
229
  },
141
230
 
142
231
  JSXAttribute(path) {
143
- if (path.node.name.name !== 'className') return
144
-
232
+ if (!isClassAttrName(jsxAttrName(path.node))) return
145
233
  const value = path.node.value
146
-
147
- // String literal: className="..."
148
234
  if (t.isStringLiteral(value)) {
149
235
  path.node.value = t.stringLiteral(prefixClasses(value.value))
236
+ return
150
237
  }
151
- // Template literal: className={`...`}
152
- else if (
153
- t.isJSXExpressionContainer(value) &&
154
- t.isTemplateLiteral(value.expression)
155
- ) {
156
- const quasis = value.expression.quasis.map((quasi, i) => {
157
- let classes = prefixClasses(quasi.value.raw)
158
-
159
- if (i < value.expression.expressions.length) classes += ' '
160
- if (i > 0 && !classes.startsWith(' ')) classes = ' ' + classes
161
-
162
- return t.templateElement({ raw: classes, cooked: classes })
163
- })
164
-
165
- // 🔥 also fix expressions inside ${ ... }
166
- const expressions = value.expression.expressions.map((expr) => {
167
- if (t.isStringLiteral(expr)) {
168
- return t.stringLiteral(prefixClasses(expr.value))
169
- }
170
- if (t.isLogicalExpression(expr)) {
171
- if (t.isStringLiteral(expr.right)) {
172
- return t.logicalExpression(
173
- expr.operator,
174
- expr.left,
175
- t.stringLiteral(prefixClasses(expr.right.value)),
176
- )
177
- }
178
- }
179
- if (t.isConditionalExpression(expr)) {
180
- const newConsequent = t.isStringLiteral(expr.consequent)
181
- ? t.stringLiteral(prefixClasses(expr.consequent.value))
182
- : expr.consequent
183
- const newAlternate = t.isStringLiteral(expr.alternate)
184
- ? t.stringLiteral(prefixClasses(expr.alternate.value))
185
- : expr.alternate
186
- return t.conditionalExpression(
187
- expr.test,
188
- newConsequent,
189
- newAlternate,
190
- )
191
- }
192
- return expr
193
- })
194
-
195
- path.node.value = t.jsxExpressionContainer(
196
- t.templateLiteral(quasis, expressions),
197
- )
198
- }
199
-
200
- // className={condition ? "..." : "..."}
201
- else if (
202
- t.isJSXExpressionContainer(value) &&
203
- t.isConditionalExpression(value.expression)
204
- ) {
205
- const expr = value.expression
206
- path.node.value = t.jsxExpressionContainer(
207
- t.conditionalExpression(
208
- expr.test,
209
- prefixStringExpr(expr.consequent),
210
- prefixStringExpr(expr.alternate),
211
- ),
212
- )
238
+ if (t.isJSXExpressionContainer(value)) {
239
+ value.expression = prefixExpr(value.expression)
213
240
  }
241
+ },
214
242
 
215
- // Other expressions (e.g., clsx())
216
- else if (
217
- t.isJSXExpressionContainer(value) &&
218
- t.isCallExpression(value.expression)
219
- ) {
220
- const args = value.expression.arguments.map((arg) => {
221
- if (t.isStringLiteral(arg))
222
- return t.stringLiteral(prefixClasses(arg.value))
223
- return arg
224
- })
225
- path.node.value = t.jsxExpressionContainer(
226
- t.callExpression(value.expression.callee, args),
227
- )
228
- }
243
+ ObjectProperty(path) {
244
+ if (path.node.computed) return
245
+ const keyName = objectKeyName(path.node.key)
246
+ if (!isClassAttrName(keyName) && !isClassBindingName(keyName)) return
247
+ path.node.value = prefixExpr(path.node.value)
229
248
  },
249
+
230
250
  VariableDeclarator(path) {
231
251
  const id = path.node.id
232
252
  const init = path.node.init
233
- if (!t.isIdentifier(id)) return
234
-
235
- // 🔹 only affect DynamicClassName variables
236
- if (!id.name.includes('DynamicClassName')) return
237
- if (!init) return
238
-
239
- const prefixString = (expr) =>
240
- t.isStringLiteral(expr)
241
- ? t.stringLiteral(prefixClasses(expr.value))
242
- : expr
243
-
244
- const prefixObject = (objExpr) => {
245
- if (!t.isObjectExpression(objExpr)) return objExpr
246
- objExpr.properties.forEach((prop) => {
247
- if (t.isObjectProperty(prop)) {
248
- if (t.isStringLiteral(prop.value)) {
249
- prop.value = t.stringLiteral(
250
- prefixClasses(prop.value.value),
251
- )
252
- } else if (t.isTemplateLiteral(prop.value)) {
253
- const quasis = prop.value.quasis.map((quasi) => {
254
- const raw = prefixClasses(quasi.value.raw)
255
- return t.templateElement({ raw, cooked: raw })
256
- })
257
- prop.value = t.templateLiteral(
258
- quasis,
259
- prop.value.expressions,
260
- )
261
- } else if (t.isObjectExpression(prop.value)) {
262
- prop.value = prefixObject(prop.value) // recurse
263
- } else if (t.isConditionalExpression(prop.value)) {
264
- prop.value = t.conditionalExpression(
265
- prop.value.test,
266
- prefixString(prop.value.consequent),
267
- prefixString(prop.value.alternate),
268
- )
269
- } else if (t.isLogicalExpression(prop.value)) {
270
- const right = t.isStringLiteral(prop.value.right)
271
- ? t.stringLiteral(prefixClasses(prop.value.right.value))
272
- : prop.value.right
273
- prop.value = t.logicalExpression(
274
- prop.value.operator,
275
- prop.value.left,
276
- right,
277
- )
278
- }
279
- }
280
- })
281
- return objExpr
282
- }
283
-
284
- if (t.isStringLiteral(init)) {
285
- path.node.init = t.stringLiteral(prefixClasses(init.value))
286
- } else if (t.isTemplateLiteral(init)) {
287
- const quasis = init.quasis.map((quasi) => {
288
- const raw = prefixClasses(quasi.value.raw)
289
- return t.templateElement({ raw, cooked: raw })
290
- })
291
- path.node.init = t.templateLiteral(quasis, init.expressions)
292
- } else if (t.isObjectExpression(init)) {
293
- path.node.init = prefixObject(init)
294
- } else if (t.isConditionalExpression(init)) {
295
- path.node.init = t.conditionalExpression(
296
- init.test,
297
- prefixString(init.consequent),
298
- prefixString(init.alternate),
299
- )
300
- } else if (t.isLogicalExpression(init)) {
301
- const right = t.isStringLiteral(init.right)
302
- ? t.stringLiteral(prefixClasses(init.right.value))
303
- : init.right
304
- path.node.init = t.logicalExpression(
305
- init.operator,
306
- init.left,
307
- right,
308
- )
309
- }
253
+ if (!t.isIdentifier(id) || !init) return
254
+ if (!isClassBindingName(id.name)) return
255
+ path.node.init = prefixExpr(init)
310
256
  },
311
257
  })
312
258
 
@@ -322,8 +268,11 @@ export default function TailwindPrefixPlugin() {
322
268
 
323
269
  // Handles:
324
270
  // ✅ className="..."
325
- // ✅ className={\`...\`}with${"foo"}inside
326
- // ✅className={cond ? "a" : "b"}
327
- // ✅className={cond && "foo"}
328
- // ✅className={clsx("foo", cond && "bar")}
329
- // ✅const fooDynamicClassName = "..." | {...} | cond ? ... : ...`
271
+ // ✅ containerClassName / *ClassName props
272
+ // ✅ className={`...`} with ${"foo"} inside
273
+ // ✅ className={cond ? "a" : "b"}
274
+ // ✅ className={cond && "foo"}
275
+ // ✅ className={clsx("foo", cond && "bar")}
276
+ // ✅ const fooDynamicClassName = "..." | {...} | cond ? ... : ...
277
+ // ✅ const BUTTON_CLASS / CARDS_GRID_CLASS = "..."
278
+ // ✅ const paginationItemBaseClass / navBtnClass = `...`
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import TailwindPrefixPlugin, {
5
+ isClassAttrName,
6
+ isClassBindingName,
7
+ } from './prefix-plugin.js'
8
+
9
+ function transform(code, id = 'src/Button.tsx') {
10
+ return TailwindPrefixPlugin().transform(code, id)
11
+ }
12
+
13
+ describe('isClassBindingName', () => {
14
+ it('matches DynamicClassName, _CLASS, ClassName, and Class suffixes', () => {
15
+ assert.equal(isClassBindingName('fooDynamicClassName'), true)
16
+ assert.equal(isClassBindingName('CARDS_GRID_CLASS'), true)
17
+ assert.equal(isClassBindingName('paginationItemBaseClass'), true)
18
+ assert.equal(isClassBindingName('navBtnClass'), true)
19
+ assert.equal(isClassBindingName('containerClassName'), true)
20
+ assert.equal(isClassBindingName('LABEL'), false)
21
+ assert.equal(isClassBindingName('FOO_CLASS_NAME'), false)
22
+ })
23
+ })
24
+
25
+ describe('isClassAttrName', () => {
26
+ it('matches className and *ClassName props', () => {
27
+ assert.equal(isClassAttrName('className'), true)
28
+ assert.equal(isClassAttrName('containerClassName'), true)
29
+ assert.equal(isClassAttrName('pageLinkClassName'), true)
30
+ assert.equal(isClassAttrName('id'), false)
31
+ assert.equal(isClassAttrName('classNames'), false)
32
+ })
33
+ })
34
+
35
+ describe('vite-tailwind-prefix VariableDeclarator', () => {
36
+ it('prefixes DynamicClassName string bindings', () => {
37
+ const result = transform('const fooDynamicClassName = "flex p-4"')
38
+ assert.match(result.code, /dw:!flex/)
39
+ assert.match(result.code, /dw:!p-4/)
40
+ })
41
+
42
+ it('prefixes export const *_CLASS strings (no semicolon)', () => {
43
+ const result = transform(
44
+ "export const CARDS_GRID_CLASS = 'grid grid-cols-12 md:gap-4 gap-3'\n",
45
+ 'src/dashboard-layout.constants.ts',
46
+ )
47
+ assert.match(result.code, /dw:!grid/)
48
+ assert.match(result.code, /dw:!grid-cols-12/)
49
+ assert.match(result.code, /dw:md:!gap-4/)
50
+ })
51
+
52
+ it('prefixes *_CLASS object bindings', () => {
53
+ const result = transform('const CARD_CLASS = { base: "flex gap-2" }')
54
+ assert.match(result.code, /dw:!flex/)
55
+ assert.match(result.code, /dw:!gap-2/)
56
+ })
57
+
58
+ it('prefixes paginationItemBaseClass strings', () => {
59
+ const result = transform(
60
+ "const paginationItemBaseClass = 'inline-flex shrink-0 items-center'",
61
+ )
62
+ assert.match(result.code, /dw:!inline-flex/)
63
+ })
64
+
65
+ it('prefixes navBtnClass template returns', () => {
66
+ const result = transform(
67
+ 'const navBtnClass = (disabled: boolean) => `inline-flex ${disabled ? "opacity-40" : "cursor-pointer"}`',
68
+ )
69
+ assert.match(result.code, /dw:!inline-flex/)
70
+ assert.match(result.code, /dw:!opacity-40/)
71
+ assert.match(result.code, /dw:!cursor-pointer/)
72
+ })
73
+
74
+ it('does not prefix unrelated constants', () => {
75
+ const result = transform('const LABEL = "flex p-4"')
76
+ assert.match(result.code, /"flex p-4"/)
77
+ assert.doesNotMatch(result.code, /dw:/)
78
+ })
79
+ })
80
+
81
+ describe('vite-tailwind-prefix JSX *ClassName props', () => {
82
+ it('prefixes className string attributes', () => {
83
+ const result = transform('export const A = () => <div className="flex p-4" />')
84
+ assert.match(result.code, /dw:!flex/)
85
+ })
86
+
87
+ it('prefixes containerClassName and other *ClassName props', () => {
88
+ const result = transform(
89
+ 'export const A = () => <ReactPaginate containerClassName="w-max inline-flex items-center gap-1" pageClassName="list-none" />',
90
+ )
91
+ assert.match(result.code, /dw:!w-max/)
92
+ assert.match(result.code, /dw:!inline-flex/)
93
+ assert.match(result.code, /dw:!list-none/)
94
+ })
95
+
96
+ it('prefixes pageLinkClassName template literals', () => {
97
+ const result = transform(
98
+ 'export const A = () => <ReactPaginate pageLinkClassName={`${paginationItemBaseClass} cursor-pointer hover:bg-secondary/5`} />',
99
+ )
100
+ assert.match(result.code, /dw:!cursor-pointer/)
101
+ assert.match(result.code, /dw:hover:!bg-secondary\/5/)
102
+ })
103
+ })
104
+
105
+ describe('vite-tailwind-prefix return helpers', () => {
106
+ it('prefixes functions named *_CLASS', () => {
107
+ const result = transform('function getButton_CLASS() { return "flex" }')
108
+ assert.match(result.code, /dw:!flex/)
109
+ })
110
+ })
@@ -115,9 +115,9 @@ function prefixQuotedStringsInExpression(expr) {
115
115
 
116
116
  // Handle className
117
117
  function prefixClassesInCode(code) {
118
- // 1) Plain string: className="..."
119
- code = code.replace(/className\s*=\s*["']([^"']*)["']/g, (_, classStr) => {
120
- return `className="${prefixStaticClasses(classStr)}"`
118
+ // 1) Plain string: className="..." / containerClassName="..." / *ClassName="..."
119
+ code = code.replace(/([A-Za-z0-9_]*[Cc]lassName)\s*=\s*["']([^"']*)["']/g, (_, attr, classStr) => {
120
+ return `${attr}="${prefixStaticClasses(classStr)}"`
121
121
  })
122
122
 
123
123
  // code = code.replace(/className\s*=\s*["']([^"']*)["']/g, (_, classStr) => {
@@ -128,8 +128,8 @@ function prefixClassesInCode(code) {
128
128
  // return `className="${prefixStaticClasses(classStr)}"`
129
129
  // })
130
130
 
131
- // 2) Template literal: className={`...`}
132
- code = code.replace(/className\s*=\s*{\s*`([\s\S]*?)`\s*}/g, (_, tplBody) => {
131
+ // 2) Template literal: className={`...`} / *ClassName={`...`}
132
+ code = code.replace(/([A-Za-z0-9_]*[Cc]lassName)\s*=\s*{\s*`([\s\S]*?)`\s*}/g, (_, attr, tplBody) => {
133
133
  const parts = tplBody.split(/(\$\{[\s\S]*?\})/g)
134
134
 
135
135
  // const rebuilt = parts
@@ -161,13 +161,13 @@ function prefixClassesInCode(code) {
161
161
  .join(' ')
162
162
  .replace(/\s+/g, ' ') // collapse multiple spaces
163
163
 
164
- return `className={\`${rebuilt.trim()}\`}`
164
+ return `${attr}={\`${rebuilt.trim()}\`}`
165
165
  })
166
166
 
167
- // 3) Ternary expressions: className={cond ? "..." : "..."}
167
+ // 3) Ternary expressions: className={cond ? "..." : "..."} / *ClassName={...}
168
168
  code = code.replace(
169
- /className\s*=\s*{([^{};]+?\?[^:]+:[^}]+)}/g,
170
- (match, inner) => {
169
+ /([A-Za-z0-9_]*[Cc]lassName)\s*=\s*{([^{};]+?\?[^:]+:[^}]+)}/g,
170
+ (match, attr, inner) => {
171
171
  const ternaryMatch = inner.match(
172
172
  /^(.*?\?)\s*(['"][^'"]*['"])\s*:\s*(['"][^'"]*['"])\s*$/,
173
173
  )
@@ -183,42 +183,43 @@ function prefixClassesInCode(code) {
183
183
  (_, q, cls) => `${q}${prefixStaticClasses(cls)}${q}`,
184
184
  )
185
185
 
186
- return `className={${condition} ${prefixedTrue} : ${prefixedFalse}}`
186
+ return `${attr}={${condition} ${prefixedTrue} : ${prefixedFalse}}`
187
187
  },
188
188
  )
189
189
 
190
- // 4) Variables with "DynamicClassName" (supports TypeScript annotations like `: any`)
190
+ // 4) Variables with "DynamicClassName" or suffix `_CLASS` (quoted; no semicolon required)
191
191
  code = code.replace(
192
- /(const|let|var)\s+([A-Za-z0-9_]*DynamicClassName[A-Za-z0-9_]*)\s*(?::\s*([^=]+))?\s*=\s*([\s\S]*?);/g,
192
+ /(const|let|var)\s+([A-Za-z0-9_]*(?:DynamicClassName[A-Za-z0-9_]*|_CLASS))\s*(?::\s*([^=]+))?\s*=\s*(['"`])([\s\S]*?)\4/g,
193
+ (match, decl, varName, typeAnnotation, quote, value) => {
194
+ const typeSuffix = typeAnnotation ? `: ${typeAnnotation.trim()}` : ''
195
+ if (quote === '`') {
196
+ return `${decl} ${varName}${typeSuffix} = \`${prefixTemplateLiteralBody(value)}\``
197
+ }
198
+ return `${decl} ${varName}${typeSuffix} = ${quote}${prefixStaticClasses(value)}${quote}`
199
+ },
200
+ )
201
+
202
+ // 4b) DynamicClassName / _CLASS object or ternary values (semicolon-terminated)
203
+ code = code.replace(
204
+ /(const|let|var)\s+([A-Za-z0-9_]*(?:DynamicClassName[A-Za-z0-9_]*|_CLASS))\s*(?::\s*([^=]+))?\s*=\s*(\{[\s\S]*?\}|.+\?.+:.+);/g,
193
205
  (match, decl, varName, typeAnnotation, value) => {
194
206
  const typeSuffix = typeAnnotation ? `: ${typeAnnotation.trim()}` : ''
195
207
  const trimmed = value.trim()
196
- if (/^\{[\s\S]*\}$/.test(trimmed)) {
197
- return `${decl} ${varName}${typeSuffix} = ${prefixValueStrings(value)};`
198
- }
199
- if (/\?.*:/.test(trimmed)) {
208
+ if (/^\{[\s\S]*\}$/.test(trimmed) || /\?.*:/.test(trimmed)) {
200
209
  return `${decl} ${varName}${typeSuffix} = ${prefixValueStrings(value)};`
201
210
  }
202
- if (/^["'`]/.test(trimmed)) {
203
- if (trimmed.startsWith('`')) {
204
- const body = trimmed.slice(1, -1)
205
- return `${decl} ${varName}${typeSuffix} = \`${prefixTemplateLiteralBody(body)}\`;`
206
- }
207
- const cls = trimmed.slice(1, -1)
208
- return `${decl} ${varName}${typeSuffix} = "${prefixStaticClasses(cls)}";`
209
- }
210
211
  return match
211
212
  },
212
213
  )
213
214
 
214
- // 5) Handle classNames(...) or clsx(...)
215
+ // 5) Handle classNames(...) or clsx(...) on *ClassName props
215
216
  code = code.replace(
216
- /className\s*=\s*{?\s*(classNames|clsx)\(([\s\S]*?)\)}?/g,
217
- (match, fnName, args) => {
217
+ /([A-Za-z0-9_]*[Cc]lassName)\s*=\s*{?\s*(classNames|clsx)\(([\s\S]*?)\)}?/g,
218
+ (match, attr, fnName, args) => {
218
219
  const transformedArgs = args.replace(/(['"])(.*?)\1/g, (_, q, cls) => {
219
220
  return `${q}${prefixStaticClasses(cls)}${q}`
220
221
  })
221
- return `className={${fnName}(${transformedArgs})}`
222
+ return `${attr}={${fnName}(${transformedArgs})}`
222
223
  },
223
224
  )
224
225
 
@@ -48,7 +48,8 @@ export async function createDevServerViteConfig(options = {}) {
48
48
  ])
49
49
 
50
50
  return defineConfig(async ({ mode, command }) => {
51
- const prefixPlugins = isPrefix(mode)
51
+ const usePrefix = isPrefix(mode)
52
+ const prefixPlugins = usePrefix
52
53
  ? [
53
54
  (await import('../tailwind/prefix-plugin.js')).default(),
54
55
  (await import('../tailwind/tailwind-content-plugin.js')).default(),
@@ -61,10 +62,20 @@ export async function createDevServerViteConfig(options = {}) {
61
62
  bootstrapShimResolver({ bootstrapShim, bootstrapGreenfieldShim, kitRoot }),
62
63
  resolveFromProjectRoot(projectRoot, kitRoot),
63
64
  ...(command === 'serve' ? [sdkLogBridge(), moduleConfigStore(projectRoot)] : []),
65
+ // Prefix must run before React / compiler so JSX className is still in the source.
66
+ ...prefixPlugins,
64
67
  ...(hasTanStackRoutes ? [TanStackRouterVite({ autoCodeSplitting: false })] : []),
65
- viteReact(createDevReactPluginOptions()),
68
+ viteReact(
69
+ usePrefix
70
+ ? {
71
+ babel: {
72
+ // Match production: compiler rewrites className after the prefix pass.
73
+ plugins: [],
74
+ },
75
+ }
76
+ : createDevReactPluginOptions(),
77
+ ),
66
78
  tailwindcss(),
67
- ...prefixPlugins,
68
79
  ],
69
80
  resolve: {
70
81
  dedupe: [