Skip to content

Commit d7ebffe

Browse files
authored
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`.
1 parent 92d943e commit d7ebffe

7 files changed

Lines changed: 248 additions & 4 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"tailwindcss-obfuscator": minor
3+
---
4+
5+
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.

packages/tailwindcss-obfuscator/src/core/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ const DEFAULT_OPTIONS: ResolvedObfuscatorOptions = {
168168
strategy: "merge",
169169
},
170170
trackPositions: false,
171+
scanObjectStrings: false,
171172
};
172173

173174
/**
@@ -223,6 +224,7 @@ export function resolveOptions(options: ObfuscatorOptions = {}): ResolvedObfusca
223224
strategy: options.cache?.strategy ?? DEFAULT_OPTIONS.cache.strategy,
224225
},
225226
trackPositions: options.trackPositions ?? DEFAULT_OPTIONS.trackPositions,
227+
scanObjectStrings: options.scanObjectStrings ?? DEFAULT_OPTIONS.scanObjectStrings,
226228
};
227229
}
228230

packages/tailwindcss-obfuscator/src/core/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,25 @@ export interface ObfuscatorOptions {
297297
* @default false
298298
*/
299299
trackPositions?: boolean;
300+
301+
/**
302+
* Also extract Tailwind classes from string literals stored in object
303+
* properties / factory args / lookup tables that the standard JSX /
304+
* `cn()` / `cva()` / `tv()` walkers don't reach.
305+
*
306+
* Heuristic-gated to avoid false positives : a string is picked up only
307+
* if it contains ≥2 whitespace-separated tokens AND every token validates
308+
* as a Tailwind class. Single-word utility strings (`"flex"`, `"red"`)
309+
* are NEVER auto-extracted — wrap them in `cn(…)` or add them to
310+
* `safelist` instead.
311+
*
312+
* Default `false` to preserve historical behavior, but recommended `true`
313+
* for any project that stores className strings in lookup tables (e.g.
314+
* status-color tables, badge variants by level).
315+
*
316+
* @default false
317+
*/
318+
scanObjectStrings?: boolean;
300319
}
301320

302321
/**
@@ -346,6 +365,7 @@ export interface ResolvedObfuscatorOptions {
346365
mapping: ResolvedMappingOutputOptions;
347366
cache: ResolvedCacheOptions;
348367
trackPositions: boolean;
368+
scanObjectStrings: boolean;
349369
}
350370

351371
/**

packages/tailwindcss-obfuscator/src/extractors/index.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import fg from "fast-glob";
88
import type { ExtractionResult, ExtractorFn, PluginContext, Logger } from "../core/types.js";
99
import { addClasses } from "../core/context.js";
1010
import { extractFromHtml } from "./html.js";
11-
import { extractAllFromJsx } from "./jsx.js";
11+
import { extractAllFromJsx, extractClassListLikeStrings } from "./jsx.js";
1212
import { extractFromCss, extractFromTailwindV4Css, detectTailwindVersion } from "./css.js";
1313
import { deduplicateClasses } from "./base.js";
1414

@@ -18,6 +18,7 @@ export {
1818
extractFromJsxWithCva,
1919
extractAllFromJsx,
2020
extractFromTailwindVariants,
21+
extractClassListLikeStrings,
2122
} from "./jsx.js";
2223
export { extractFromCss, extractFromTailwindV4Css, detectTailwindVersion } from "./css.js";
2324
export * from "./base.js";
@@ -60,8 +61,16 @@ export function getExtractor(filePath: string): ExtractorFn | null {
6061

6162
/**
6263
* Extract classes from a single file
64+
*
65+
* `opts.scanObjectStrings` opts into the lookup-table-string extractor
66+
* (`extractClassListLikeStrings`), which catches Tailwind class strings
67+
* stored in object-property values that the standard JSX walker doesn't
68+
* reach. Default off — see `scanObjectStrings` in `ObfuscatorOptions`.
6369
*/
64-
export async function extractFromFile(filePath: string): Promise<ExtractionResult> {
70+
export async function extractFromFile(
71+
filePath: string,
72+
opts: { scanObjectStrings?: boolean } = {}
73+
): Promise<ExtractionResult> {
6574
const result: ExtractionResult = {
6675
file: filePath,
6776
classes: [],
@@ -75,6 +84,18 @@ export async function extractFromFile(filePath: string): Promise<ExtractionResul
7584
if (extractor) {
7685
const classes = await extractor(content, filePath);
7786
result.classes = classes;
87+
88+
// Optional second pass: scan ALL string literals for class-list-like
89+
// contents (handles class strings stored in object property values).
90+
// Only relevant for JS-family files where the primary extractor is
91+
// `extractAllFromJsx` — for HTML / CSS files, classes inside string
92+
// literals are already handled by their dedicated extractors.
93+
if (opts.scanObjectStrings && JS_FAMILY_EXTENSIONS.has(getExtension(filePath))) {
94+
const extra = extractClassListLikeStrings(content, filePath);
95+
if (extra.length > 0) {
96+
result.classes = [...new Set([...result.classes, ...extra])];
97+
}
98+
}
7899
} else {
79100
result.errors = [`No extractor available for file type: ${filePath}`];
80101
}
@@ -87,6 +108,8 @@ export async function extractFromFile(filePath: string): Promise<ExtractionResul
87108
return result;
88109
}
89110

111+
const JS_FAMILY_EXTENSIONS = new Set(["js", "jsx", "ts", "tsx", "vue", "svelte", "astro"]);
112+
90113
/**
91114
* Extract classes from multiple files using glob patterns
92115
*/
@@ -113,6 +136,54 @@ export async function extractFromGlob(
113136
return results;
114137
}
115138

139+
/**
140+
* Test a path against a list of patterns. RegExp entries use `.test()`;
141+
* string entries fall back to substring matching on the absolute path so that
142+
* users can write `"jose"` or `"node_modules"` without thinking in globs.
143+
*/
144+
function pathMatchesAny(filePath: string, patterns: ReadonlyArray<string | RegExp>): boolean {
145+
for (const pattern of patterns) {
146+
if (pattern instanceof RegExp) {
147+
if (pattern.test(filePath)) return true;
148+
} else if (filePath.includes(pattern)) {
149+
return true;
150+
}
151+
}
152+
return false;
153+
}
154+
155+
/**
156+
* Apply `sources.include` (whitelist) and `sources.exclude` (blacklist) to a
157+
* file list. Earlier versions accepted these options in the config schema but
158+
* never enforced them — leading to bug reports where archived HTML/CSS under
159+
* gitignored personal directories ended up in the class mapping anyway and
160+
* corrupted SSR chunks (e.g. `require('util')` rewritten because a `class="util"`
161+
* appeared in some `*.html` snippet outside the source tree).
162+
*
163+
* Patterns are matched against the **relative** path (relative to `basePath`),
164+
* never the absolute path — otherwise an exclude like `/[\\/]jose[\\/]/` would
165+
* silently match every file under `/Users/jose/...` on the maintainer's machine.
166+
*/
167+
function applySourcesFilter(
168+
files: string[],
169+
sources: PluginContext["options"]["sources"] | undefined,
170+
basePath: string
171+
): string[] {
172+
if (!sources) return files;
173+
const include = sources.include;
174+
const exclude = sources.exclude;
175+
if ((!include || include.length === 0) && (!exclude || exclude.length === 0)) return files;
176+
177+
return files.filter((file) => {
178+
// Always test against POSIX-style relative paths so users can write
179+
// `'jose/'` regardless of the host OS.
180+
const relative = path.relative(basePath, file).split(path.sep).join("/");
181+
if (include && include.length > 0 && !pathMatchesAny(relative, include)) return false;
182+
if (exclude && exclude.length > 0 && pathMatchesAny(relative, exclude)) return false;
183+
return true;
184+
});
185+
}
186+
116187
/**
117188
* Extract all classes from files and add to context
118189
*/
@@ -128,9 +199,17 @@ export async function extractClasses(
128199
logger.info("Extracting Tailwind classes...");
129200

130201
// Extract from content files (HTML, JSX, TSX, etc.)
131-
const contentResults = await extractFromGlob(ctx.options.content, {
202+
const matchedFiles = await fg(ctx.options.content, {
132203
cwd: basePath,
204+
ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**", "**/.git/**"],
205+
absolute: true,
133206
});
207+
const filteredFiles = applySourcesFilter(matchedFiles, ctx.options.sources, basePath);
208+
209+
const scanObjectStrings = ctx.options.scanObjectStrings === true;
210+
const contentResults = await Promise.all(
211+
filteredFiles.map((file) => extractFromFile(file, { scanObjectStrings }))
212+
);
134213

135214
for (const result of contentResults) {
136215
if (result.errors && result.errors.length > 0) {
@@ -166,8 +245,9 @@ export async function extractFromCssFiles(
166245
ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**"],
167246
absolute: true,
168247
});
248+
const filteredCssFiles = applySourcesFilter(cssFiles, ctx.options.sources, basePath);
169249

170-
for (const file of cssFiles) {
250+
for (const file of filteredCssFiles) {
171251
try {
172252
const content = await fs.promises.readFile(file, "utf-8");
173253

packages/tailwindcss-obfuscator/src/extractors/jsx.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,52 @@ export function extractAllFromJsx(content: string, filePath: string): string[] {
485485

486486
return deduplicateClasses(classes);
487487
}
488+
489+
/**
490+
* Extract from string literals that LOOK like Tailwind class lists.
491+
*
492+
* Required for the very common pattern of storing class strings inside object
493+
* literals or factory functions that the JSX/CVA/TV walkers don't reach :
494+
*
495+
* const COLORS = {
496+
* 1: { badge: 'border-slate-400/30 bg-slate-400/10 text-slate-600',
497+
* bar: 'bg-slate-400' },
498+
* …
499+
* };
500+
*
501+
* Without this pass, only siblings of these strings that ALSO appear inside
502+
* a `className=` attribute somewhere else in the codebase get extracted ;
503+
* the rest stay un-mapped, the CSS for them stays as-is, and at runtime the
504+
* className value points at non-existent rewritten selectors → broken design.
505+
*
506+
* Heuristic to avoid false positives on prose / config strings :
507+
* - String must contain ≥2 whitespace-separated tokens
508+
* - EVERY token must validate as a Tailwind class via `isTailwindClass`
509+
*
510+
* Single-word strings (`"red"`, `"info"`, `"flex"`) are NEVER picked up here ;
511+
* those would be ambiguous with JS identifiers / config values. If the user
512+
* wants single-word utility strings extracted, they should wrap them in a
513+
* recognised utility (`cn('flex')`) or add them to `safelist`.
514+
*/
515+
export function extractClassListLikeStrings(content: string, _filePath: string): string[] {
516+
const classes: string[] = [];
517+
let match: RegExpExecArray | null;
518+
519+
STRING_LITERAL_PATTERN.lastIndex = 0;
520+
while ((match = STRING_LITERAL_PATTERN.exec(content)) !== null) {
521+
const raw = match[1];
522+
if (!raw || raw.length < 3) continue;
523+
// Strip ${…} so template-literal expressions don't poison the per-token
524+
// validation.
525+
const cleaned = raw.replace(/\$\{[^}]*\}/g, " ");
526+
const tokens = cleaned.split(/\s+/).filter(Boolean);
527+
if (tokens.length < 2) continue;
528+
// Every token must look like a Tailwind class — otherwise this is prose,
529+
// a config map, an i18n key, or some other non-class string.
530+
if (!tokens.every((t) => isTailwindClass(t))) continue;
531+
classes.push(...tokens);
532+
}
533+
STRING_LITERAL_PATTERN.lastIndex = 0;
534+
535+
return deduplicateClasses(classes);
536+
}

packages/tailwindcss-obfuscator/src/internals.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export {
7474
extractFromJsxWithCva,
7575
extractFromTailwindVariants,
7676
extractAllFromJsx,
77+
extractClassListLikeStrings,
7778
extractFromCss,
7879
extractFromTailwindV4Css,
7980
extractFromFile,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, it, expect } from "vitest";
2+
import { extractClassListLikeStrings } from "../src/extractors/jsx.js";
3+
4+
describe("extractClassListLikeStrings — lookup-table extraction", () => {
5+
it("picks up class strings stored in object property values", () => {
6+
const src = `
7+
export const CONFIDENCE_LEVEL_COLORS = {
8+
1: { badge: 'border-slate-400/30 bg-slate-400/10 text-slate-600 dark:text-slate-400', bar: 'bg-slate-400' },
9+
2: { badge: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400', bar: 'bg-amber-500' },
10+
3: { badge: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-400', bar: 'bg-sky-500' },
11+
};
12+
`;
13+
const classes = extractClassListLikeStrings(src, "ConfidenceBadge.tsx");
14+
expect(classes).toContain("border-slate-400/30");
15+
expect(classes).toContain("bg-slate-400/10");
16+
expect(classes).toContain("text-slate-600");
17+
expect(classes).toContain("border-sky-500/30");
18+
expect(classes).toContain("bg-sky-500/10");
19+
expect(classes).toContain("text-sky-700");
20+
expect(classes).toContain("dark:text-sky-400");
21+
// Single-token strings (`'bg-slate-400'`) are intentionally NOT included
22+
// in this pass — they require the user to wrap them or safelist.
23+
expect(classes).not.toContain("bg-slate-400");
24+
});
25+
26+
it("picks up template-literal class strings in object positions", () => {
27+
const src = "const x = { foo: `flex items-center gap-2` };";
28+
const classes = extractClassListLikeStrings(src, "x.ts");
29+
expect(classes).toEqual(expect.arrayContaining(["flex", "items-center", "gap-2"]));
30+
});
31+
32+
it("does NOT extract from prose strings (unrecognized tokens)", () => {
33+
const src = `
34+
const labels = {
35+
en: "Hello world",
36+
fr: "Bonjour le monde",
37+
ja: "こんにちは 世界",
38+
};
39+
`;
40+
const classes = extractClassListLikeStrings(src, "labels.ts");
41+
expect(classes).toHaveLength(0);
42+
});
43+
44+
it("does NOT extract single-word strings even when they look like utilities", () => {
45+
const src = `
46+
const role = "info";
47+
const color = "red";
48+
const layout = "flex";
49+
`;
50+
const classes = extractClassListLikeStrings(src, "config.ts");
51+
expect(classes).toHaveLength(0);
52+
});
53+
54+
it("does NOT extract config strings where some tokens are non-Tailwind", () => {
55+
// Mixed — only "rounded" is a class, the rest are prose. Should be skipped.
56+
const src = `
57+
const help = "Click rounded the button to continue";
58+
`;
59+
const classes = extractClassListLikeStrings(src, "x.ts");
60+
expect(classes).toHaveLength(0);
61+
});
62+
63+
it("strips template-literal expressions before per-token validation", () => {
64+
const src = "const x = `flex items-center ${dynamic} gap-2`;";
65+
const classes = extractClassListLikeStrings(src, "x.ts");
66+
expect(classes).toEqual(expect.arrayContaining(["flex", "items-center", "gap-2"]));
67+
});
68+
69+
it("deduplicates repeated tokens across multiple strings", () => {
70+
const src = `
71+
const a = "flex items-center";
72+
const b = "flex items-center justify-between";
73+
`;
74+
const classes = extractClassListLikeStrings(src, "x.ts");
75+
const flexCount = classes.filter((c) => c === "flex").length;
76+
expect(flexCount).toBe(1);
77+
expect(classes).toContain("justify-between");
78+
});
79+
80+
it("ignores strings shorter than 3 characters", () => {
81+
const src = `const x = { a: "ab", b: "cd" };`;
82+
const classes = extractClassListLikeStrings(src, "x.ts");
83+
expect(classes).toHaveLength(0);
84+
});
85+
});

0 commit comments

Comments
 (0)