Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ yarn-error.log*
lib/

# Manual testing environment
test-env/
test-env/

# Benchmark output
bench/.out/
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,14 @@ function Button({ variant, className }) {
3. **Canonicalize**: For each class, it uses Tailwind's `canonicalizeCandidates` to find the canonical form
4. **Report & Fix**: If a non-canonical class is found, it reports an error/warning and can auto-fix it

### Cache

Tailwind needs about a second to build its canonicalization index the first time it sees a class. To skip that on later runs, the plugin stores every class it has canonicalized in `node_modules/.cache/eslint-plugin-tailwind-canonical-classes/` under the ESLint working directory, or in the system temp directory when there is no `node_modules`. A run that finds all of its classes there never starts Tailwind.

The cache is tied to the plugin version, `rootFontSize`, and every file Tailwind read to load your CSS: the stylesheet, its local imports, and imported package files such as `tailwindcss/index.css`. Editing or upgrading any of them starts a fresh cache, including inside a running editor or `eslint_d`.

Set `TAILWIND_CANONICAL_CLASSES_CACHE_DIR` to store the cache somewhere else, for example a directory your CI caches between runs.

## ⚠️ Limitations

- **Static classes only**: Only works with static class names (no dynamic expressions)
Expand Down
66 changes: 66 additions & 0 deletions bench/generate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import fs from 'node:fs';
import path from 'node:path';

const [outDir, fileCountArg = '400', elementsArg = '40'] = process.argv.slice(2);
const fileCount = Number(fileCountArg);
const elementsPerFile = Number(elementsArg);

let seed = 42;
const random = () => {
seed = (seed * 1103515245 + 12345) % 2 ** 31;
return seed / 2 ** 31;
};
const pick = (pool) => pool[Math.floor(random() * pool.length)];

const canonical = [
'flex', 'grid', 'block', 'hidden', 'items-center', 'justify-between', 'gap-2',
'gap-4', 'p-2', 'p-4', 'px-3', 'py-1', 'mt-2', 'mb-4', 'rounded-md', 'rounded-lg',
'text-sm', 'text-lg', 'font-medium', 'font-bold', 'text-gray-700', 'bg-white',
'bg-blue-500', 'hover:bg-blue-600', 'shadow-sm', 'border', 'border-gray-200',
'w-full', 'h-10', 'md:flex', 'lg:grid-cols-3', 'truncate', 'opacity-50',
];
const nonCanonical = [
'w-[16px]', 'h-[32px]', 'p-[4px]', 'm-[8px]', 'mt-[12px]', 'gap-[8px]',
'bg-[#fff]', 'bg-[#000]', 'px-[16px]', 'rounded-[4px]',
];
const classList = () => {
const count = 3 + Math.floor(random() * 6);
const classes = new Set();
while (classes.size < count) {
classes.add(random() < 0.1 ? pick(nonCanonical) : pick(canonical));
}
return [...classes].join(' ');
};

const srcDir = path.join(outDir, 'src');
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'tailwind.css'), '@import "tailwindcss";\n');

for (let f = 0; f < fileCount; f++) {
const lines = [
"import { cn } from './cn';",
'',
`export function Component${f}({ active, items }) {`,
' const total = items.reduce((sum, item) => sum + item.value, 0);',
' console.log(String(total), Math.max(total, 0));',
' return (',
' <div>',
];
for (let e = 0; e < elementsPerFile; e++) {
const kind = random();
if (kind < 0.6) {
lines.push(` <div className="${classList()}">{format(items[${e}])}</div>`);
} else if (kind < 0.9) {
lines.push(
` <span className={cn("${classList()}", active && "${classList()}")}>{String(${e})}</span>`,
);
} else {
lines.push(` <button onClick={() => handle(${e}, items.map((i) => i.id))}>x</button>`);
}
}
lines.push(' </div>', ' );', '}', '');
fs.writeFileSync(path.join(srcDir, `component-${f}.jsx`), lines.join('\n'));
}

console.log(`generated ${fileCount} files x ${elementsPerFile} elements in ${outDir}`);
55 changes: 55 additions & 0 deletions bench/lint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { ESLint } from 'eslint';

const [pluginDir, projectArg] = process.argv.slice(2);
const projectDir = path.resolve(projectArg);
const start = performance.now();
const control = pluginDir === 'none';
const plugin = control
? null
: (await import(pathToFileURL(path.resolve(pluginDir, 'lib/index.js')).href)).default;

const eslint = new ESLint({
cwd: projectDir,
overrideConfigFile: true,
overrideConfig: [
{
files: ['**/*.jsx'],
languageOptions: {
parserOptions: { ecmaFeatures: { jsx: true } },
},
...(control
? {}
: {
plugins: { tw: plugin },
rules: {
'tw/tailwind-canonical-classes': ['warn', { cssPath: 'tailwind.css' }],
},
}),
},
],
});

const results = await eslint.lintFiles(['src/**/*.jsx']);
const elapsedMs = performance.now() - start;

let warnings = 0;
let fatal = 0;
const byMessage = new Map();
for (const result of results) {
for (const message of result.messages) {
if (message.fatal || message.severity === 2) fatal++;
else warnings++;
byMessage.set(message.message, (byMessage.get(message.message) ?? 0) + 1);
}
}

const digest = [...byMessage.entries()].sort().map(([m, n]) => `${n}\t${m}`).join('\n');
if (process.env.BENCH_DIGEST) fs.writeFileSync(process.env.BENCH_DIGEST, digest);

console.log(
JSON.stringify({ files: results.length, warnings, fatal, elapsedMs: Math.round(elapsedMs) }),
);
process.exit(0);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"build:watch": "tsc --watch",
"test": "npm run build && vitest",
"test:run": "npm run build && vitest run",
"bench": "npm run build && node bench/generate.mjs bench/.out/project 400 40 && node bench/lint.mjs . bench/.out/project",
"prepublishOnly": "npm run build",
"release": "semantic-release"
},
Expand Down
158 changes: 158 additions & 0 deletions src/rules/canonical-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createSyncFn } from 'synckit';
import { statStamp } from './file-stamp.js';
import type { CanonicalizeInWorker, CanonicalizeResult } from './tailwind-worker.js';

const workerPath = fileURLToPath(new URL('./tailwind-worker.js', import.meta.url));
const pluginVersion = (
createRequire(import.meta.url)('../../package.json') as { version: string }
).version;

type CanonicalizeSync = (
...args: Parameters<CanonicalizeInWorker>
) => CanonicalizeResult;

// Spawned on the first cache miss so fully cached runs never start Tailwind.
let canonicalizeSync: CanonicalizeSync | undefined;

/**
* Canonical forms for one stylesheet, valid while every file Tailwind read to
* build it (the stylesheet plus each dependency it reported) is unchanged.
*/
interface StylesheetCache {
files: string[];
stamp: string;
key: string;
canonicalByRem: Map<number, Map<string, string>>;
}

const stylesheets = new Map<string, StylesheetCache>();

function isPackageRoot(dir: string): boolean {
const parent = path.dirname(dir);
return (
path.basename(parent) === 'node_modules' ||
(path.basename(parent).startsWith('@') && path.basename(path.dirname(parent)) === 'node_modules')
);
}

function packageVersionOf(file: string): string {
let dir = path.dirname(file);
while (!isPackageRoot(dir) && dir !== path.dirname(dir)) {
dir = path.dirname(dir);
}
try {
return (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')) as { version?: string }).version ?? '';
} catch {
return '';
}
}

function contentKey(files: string[]): string {
const hash = createHash('sha256').update(pluginVersion);
for (const file of files) {
hash.update(`\0${file}\0`);
try {
hash.update(fs.readFileSync(file));
} catch {
hash.update('missing');
}
if (file.includes(`${path.sep}node_modules${path.sep}`)) {
hash.update(`\0${packageVersionOf(file)}`);
}
}
return hash.digest('hex');
}

function readJson<T>(file: string): T | undefined {
try {
return JSON.parse(fs.readFileSync(file, 'utf-8')) as T;
} catch {
return undefined;
}
}

function writeJson(file: string, value: unknown): void {
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${randomUUID()}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(value));
fs.renameSync(tmp, file);
} catch {
// An unwritable cache only costs later runs their warm start.
}
}

function loadStylesheet(cacheDir: string, cssPath: string, files: string[]): StylesheetCache {
const cache: StylesheetCache = {
files,
stamp: statStamp(files),
key: contentKey(files),
canonicalByRem: new Map(),
};
stylesheets.set(cssPath, cache);
writeJson(dependencyManifestPath(cacheDir, cssPath), files);
return cache;
}

function dependencyManifestPath(cacheDir: string, cssPath: string): string {
return path.join(cacheDir, `${createHash('sha256').update(cssPath).digest('hex')}.deps.json`);
}

function tablePath(cacheDir: string, key: string, rem: number): string {
return path.join(cacheDir, `${key}-${rem}.json`);
}

function getStylesheet(cacheDir: string, cssPath: string): StylesheetCache {
const cached = stylesheets.get(cssPath);
if (cached && statStamp(cached.files) === cached.stamp) {
return cached;
}
const known = readJson<string[]>(dependencyManifestPath(cacheDir, cssPath)) ?? [cssPath];
return loadStylesheet(cacheDir, cssPath, known);
}

/**
* Returns a function mapping each class to its canonical form for `cssPath`.
* Answers come from memory, then `cacheDir`, then the Tailwind worker; worker
* answers are written through to `cacheDir` so concurrent linters share them.
*/
export function createClassCanonicalizer(
cacheDir: string,
cssPath: string,
rem: number,
): (candidates: string[]) => string[] {
let stylesheet = getStylesheet(cacheDir, cssPath);
let canonicalByClass = stylesheet.canonicalByRem.get(rem);
if (!canonicalByClass) {
canonicalByClass = new Map(
Object.entries(readJson<Record<string, string>>(tablePath(cacheDir, stylesheet.key, rem)) ?? {}),
);
stylesheet.canonicalByRem.set(rem, canonicalByClass);
}
const table = canonicalByClass;

return (candidates) => {
const missing = [...new Set(candidates.filter((candidate) => !table.has(candidate)))];
if (missing.length > 0) {
canonicalizeSync ??= createSyncFn<CanonicalizeInWorker>(workerPath);
const { canonical, files } = canonicalizeSync(cssPath, missing, { rem });
missing.forEach((candidate, index) => table.set(candidate, canonical[index]));

if (files.join('\0') !== stylesheet.files.join('\0')) {
stylesheet = loadStylesheet(cacheDir, cssPath, files);
stylesheet.canonicalByRem.set(rem, table);
}
const file = tablePath(cacheDir, stylesheet.key, rem);
writeJson(file, {
...readJson<Record<string, string>>(file),
...Object.fromEntries(table),
});
}
return candidates.map((candidate) => table.get(candidate)!);
};
}
9 changes: 0 additions & 9 deletions src/rules/class-sources/call-expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,10 @@ import {
joinClasses,
} from './estree.js';
import type { CanonicalizationContext, ClassSource } from './types.js';
import type { Rule } from 'eslint';

export interface CallExpressionSourceOptions {
cssNotFoundNode?: Rule.Node;
}

export function buildClassSourcesFromCallExpression(
node: unknown,
ctx: CanonicalizationContext,
options: CallExpressionSourceOptions = {},
): ClassSource[] {
const callExprData = extractStringArgsFromCallExpression(
node,
Expand All @@ -29,9 +23,6 @@ export function buildClassSourcesFromCallExpression(
reportNode: arg.node,
classes: arg.classes,
fixRange: arg.node.range as [number, number],
...(options.cssNotFoundNode
? { cssNotFoundNode: options.cssNotFoundNode }
: {}),
buildFix: (fixedClasses: string[]) => {
const quoteChar = getQuoteChar(
ctx.sourceText,
Expand Down
Loading
Loading