You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(extractor): scanObjectStrings option for lookup-table class strings (#143)
## Summary
Fixes the **« obfuscator breaks design »** class of bugs where Tailwind
class strings stored in JS object lookup tables (status-color tables,
badge variants by level, etc.) produce **inconsistent obfuscation across
siblings** of the same table — diagnosed in the 2026-04-30 portfolio
session.
## The bug
The JSX extractor walks `className=` JSX attributes AND recognized
utility calls (`cn / clsx / cva / tv / twMerge / classnames`). It does
**not** walk arbitrary string literals stored in object property values:
```ts
const COLORS = {
1: { badge: 'border-slate-400/30 bg-slate-400/10 text-slate-600' },
2: { badge: 'border-amber-500/30 bg-amber-500/10 text-amber-700' },
3: { badge: 'border-sky-500/30 bg-sky-500/10 text-sky-700' },
};
```
Only siblings whose tokens **also appear in a `className=`** somewhere
else (e.g. `bg-amber-500/10` if `bg-amber-500/5` exists in some
`className=`) get into the mapping. The others stay un-mapped. Then the
CSS extractor walks the BUILT CSS and renames matching selectors →
**amber gets `.tw-XXXX` but sky stays `.bg-sky-500`**. The runtime
className still says `bg-sky-500/10` (un-rewritten in JS chunks since
the mapping doesn't have it) — fine, sky still works. But for the levels
that WERE in the mapping, the JS chunk now has `'tw-XXXX tw-XXXX'` and
the CSS has the matching rewritten selectors. **The inconsistency is
what produces the visible breakage when this pattern shows up alongside
other classes.**
## The fix
New opt-in option `scanObjectStrings: boolean` (default `false`) that
runs an additional pass over JS-family files. Conservative heuristic:
- String must contain **≥2 whitespace-separated tokens**
- **Every** token must validate as a Tailwind class via
`isTailwindClass`
Single-word strings (`'flex'`, `'red'`, `'info'`) are **NEVER**
auto-extracted — they conflict with config values, JS identifiers, i18n
keys.
## Verified end-to-end
Tested on the maintainer's portfolio:
```
Before: 1326 classes in mapping, 'bg-sky-500/10' NOT in mapping
After: 1379 classes in mapping (+53), 'bg-sky-500/10' ✅, 'border-sky-500/30' ✅, 'text-sky-700' ✅, 'dark:text-sky-400' ✅
```
The `Parcours` page (which renders the ConfidenceBadge with these
tables) loads cleanly with the option on.
## Test plan
- [x] 8 new test cases in `tests/scan-object-strings.test.ts` (positive
cases, prose rejection, single-token rejection, mixed-token rejection,
template-literal handling, deduplication)
- [x] `pnpm --filter tailwindcss-obfuscator test` → 479/479 (was
471/471)
- [x] `pnpm --filter tailwindcss-obfuscator typecheck` → 0 errors
- [x] `node scripts/verify-obfuscation.mjs` → 25/25 apps still ok with
option default-off (no behavior change for existing users)
- [x] End-to-end portfolio build with `scanObjectStrings: true` → 6 of 7
previously-missing classes now in mapping; visual rendering correct
## Acknowledged limitation
Single-token entries (`'bg-slate-400'` as a `bar:` field) still won't be
auto-obfuscated even with the option on — the heuristic intentionally
requires 2+ tokens to keep false-positive rate near zero. Users hit by
this can wrap in `cn(…)` or add to `safelist`.
New extractor option `scanObjectStrings` (default `false`) — opt-in pass that walks Tailwind class strings stored in object property values, factory args, and lookup tables that the standard JSX / `cn()` / `cva()` / `tv()` walkers don't reach. Heuristic-gated to avoid false positives: a string is picked up only if it contains ≥2 whitespace-separated tokens AND every token validates as a Tailwind class. Single-word strings (`"flex"`, `"red"`, `"info"`) are NEVER auto-extracted — wrap them in `cn(…)` or add to `safelist`.
6
+
7
+
Fixes the « broken design with obfuscator » class of bugs where status-color tables, badge variants by level, etc. produced inconsistent obfuscation across siblings of the same lookup table.
0 commit comments