mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Get Tailwind ready for general usage
This commit is contained in:
125
.eslint/rules/enforce-tw.js
Normal file
125
.eslint/rules/enforce-tw.js
Normal 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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
42
.eslint/rules/enforce-tw.test.js
Normal file
42
.eslint/rules/enforce-tw.test.js
Normal 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 }] },
|
||||
],
|
||||
});
|
||||
50
.eslint/rules/enforce-tw.worker.js
Normal file
50
.eslint/rules/enforce-tw.worker.js
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user