Get Tailwind ready for general usage

This commit is contained in:
Jamie Kyle
2025-08-11 16:46:23 -07:00
committed by GitHub
parent 237e239e05
commit b798a4f927
32 changed files with 439 additions and 190 deletions

125
.eslint/rules/enforce-tw.js Normal file
View File

@@ -0,0 +1,125 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const { createSyncFn } = require('synckit');
const worker = createSyncFn(require.resolve('./enforce-tw.worker.js'));
/** @type {import("eslint").Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
hasSuggestions: true,
fixable: true,
schema: [],
},
create(context) {
function check(input, node) {
if (typeof input !== 'string') {
throw new Error(`Unexpected input ${input} for node type ${node.type}`);
}
const tailwindClasses = worker(input.split(/\s+/));
for (const tailwindClass of tailwindClasses) {
const index = input.indexOf(tailwindClass) + 1;
const length = tailwindClass.length;
context.report({
node,
loc: {
start: {
line: node.loc.start.line,
column: node.loc.start.column + index,
},
end: {
line: node.loc.end.line,
column: node.loc.start.column + index + length,
},
},
message: 'Tailwind classes must be wrapped with tw()',
});
}
}
function traverse(node) {
if (node.type === 'Literal') {
if (typeof node.value === 'string') {
check(node.value, node);
}
// ignore other literals
} else if (node.type === 'TemplateLiteral') {
for (let element of node.quasis) {
traverse(element);
}
for (let expression of node.expressions) {
traverse(expression);
}
} else if (node.type === 'TemplateElement') {
check(node.value.cooked, node);
} else if (node.type === 'JSXExpressionContainer') {
traverse(node.expression);
} else if (node.type === 'ConditionalExpression') {
// ignore node.test
traverse(node.consequent);
traverse(node.alternate);
} else if (node.type === 'LogicalExpression') {
if (node.operator === '||' || node.operator === '??') {
traverse(node.left);
}
traverse(node.right);
} else if (node.type === 'BinaryExpression') {
if (node.operator === '+') {
traverse(node.left);
traverse(node.right);
} else {
throw new Error(`Unexpected binary operator: ${node.operator}`);
}
} else if (node.type === 'ObjectExpression') {
for (let prop of node.properties) {
traverse(prop);
}
} else if (node.type === 'Property') {
if (node.key.type === 'Identifier') {
if (!node.computed) {
check(node.key.name, node.key);
}
// ignore computed
} else if (node.key.type === 'Literal') {
traverse(node.key);
} else if (node.key.type === 'TemplateLiteral') {
traverse(node.key);
} else if (node.key.type === 'CallExpression') {
// ignore
} else {
throw new Error(`Unexpected property key type: ${node.key.type}`);
}
} else if (node.type === 'ArrayExpression') {
for (let element of node.elements) {
traverse(element);
}
} else if (node.type === 'Identifier') {
// ignore
} else if (node.type === 'CallExpression') {
// ignore
} else if (node.type === 'MemberExpression') {
// ignore
} else {
throw new Error(`Unexpected traverse node type: ${node.type}`);
}
}
return {
CallExpression(node) {
if (node.callee.type !== 'Identifier') return;
if (node.callee.name !== 'classNames') return;
for (let arg of node.arguments) {
traverse(arg);
}
},
JSXAttribute(node) {
if (node.name.type !== 'JSXIdentifier') return;
if (node.name.name !== 'className') return;
traverse(node.value);
},
};
},
};

View File

@@ -0,0 +1,42 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const rule = require('./enforce-tw');
const RuleTester = require('eslint').RuleTester;
const message = 'Tailwind classes must be wrapped with tw()';
// avoid triggering mocha's global leak detection
require('@typescript-eslint/parser');
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
});
ruleTester.run('enforce-tw', rule, {
valid: [
{ code: `classNames("foo")` },
{ code: `<div className="foo"/>` },
{ code: `tw("flex")` },
],
invalid: [
{ code: `classNames("flex")`, errors: [{ message }] },
{ code: `<div className="flex"/>`, errors: [{ message }] },
{ code: `<div className={"flex"}/>`, errors: [{ message }] },
{ code: `classNames("foo", "flex")`, errors: [{ message }] },
{ code: `classNames(cond ? "foo" : "flex")`, errors: [{ message }] },
{ code: `classNames(cond ? "flex" : "foo")`, errors: [{ message }] },
{ code: `classNames(cond && "flex")`, errors: [{ message }] },
{ code: `classNames(cond || "flex")`, errors: [{ message }] },
{ code: `classNames(cond ?? "flex")`, errors: [{ message }] },
{ code: `classNames("foo" + "flex")`, errors: [{ message }] },
{ code: `classNames("flex" + "foo")`, errors: [{ message }] },
],
});

View File

@@ -0,0 +1,50 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const { runAsWorker } = require('synckit');
const enhancedResolve = require('enhanced-resolve');
const tailwind = require('tailwindcss');
const path = require('node:path');
const fs = require('node:fs');
const rootDir = path.join(__dirname, '../..');
const tailwindCssPath = path.join(rootDir, 'stylesheets/tailwind-config.css');
async function loadDesignSystem() {
const tailwindCss = fs.readFileSync(tailwindCssPath, 'utf-8');
const resolver = enhancedResolve.create.sync({
conditionNames: ['style'],
extensions: ['.css'],
mainFields: ['style'],
});
const designSystem = await tailwind.__unstable__loadDesignSystem(
tailwindCss,
{
base: path.dirname(tailwindCssPath),
loadStylesheet(id, base) {
const resolved = resolver(base, id);
if (!resolved) {
return { base: '', content: '' };
}
return {
base: path.dirname(resolved),
content: fs.readFileSync(resolved, 'utf-8'),
};
},
}
);
return designSystem;
}
let cachedDesignSystem = null;
runAsWorker(async classNames => {
cachedDesignSystem ??= await loadDesignSystem();
const designSystem = cachedDesignSystem;
const css = designSystem.candidatesToCss(classNames);
const tailwindClassNames = classNames.filter((_, index) => {
return css.at(index) !== null;
});
return tailwindClassNames;
});