Add lint for Tailwind arbitrary values

This commit is contained in:
thePR0M3TH3AN committed 2025-10-14 19:18:14 -04:00
1 parent 478f32ec1b
commit ed60f1b7af
3 files changed
+213 -26

No files matched your search

+13 -1
View File
@@ -54,6 +54,18 @@ scale adjustments can ship from a single source of truth.
- Keep the legacy search strings (like `text-[11px]`) in regression checklists—repositories should no longer include those
selectors after migrating a template or controller.
### Tailwind arbitrary values lint
Run `npm run lint:tailwind-brackets` after touching markup or prose that embeds HTML. The lint walks `.html`, `.njk`, and `.md`
sources and fails when Tailwind's bracket syntax introduces hard-coded measurements (`text-[11px]`, `max-w-[18rem]`, etc.).
- Prefer the semantic utilities shipped alongside the tokens (`max-w-popover-safe`, `max-h-modal-shell`, `text-3xs`).
- When you truly need an arbitrary value, drive it with a design token by referencing the CSS custom property (`w-[var(--blog-layout-fluid-width)]`, `min-h-[var(--form-textarea-min-height)]`).
- Update the allowlist sparingly—every entry should correspond to a documented token so future adjustments cascade automatically.
The lint shares the same underlying script as `npm run lint:tokens`, so both commands stay in lockstep with the design-system
guardrails.
## Theming
### `[data-theme]` contract
@@ -385,7 +397,7 @@ Compose `.popover` with a zap-specific panel variant when you need to surface th
<img src="assets/svg/lightning-bolt.svg" alt="Zap" class="h-5 w-5" />
</button>
<div
class="popover__panel card w-72 max-w-[calc(100vw-2rem)] space-y-4"
class="popover__panel card w-72 max-w-popover-safe space-y-4"
role="dialog"
aria-hidden="true"
data-variant="zap"
+3 -2
View File
@@ -9,11 +9,12 @@
"build:beacon": "npm run build:beacon:bundle && npm run lint:inline-styles",
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css \"docs/**/*.html\" tailwind.config.cjs README.md",
"lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-colors",
"lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors",
"lint:css": "stylelint css/tailwind.source.css css/tokens.css",
"lint:hex": "node scripts/check-hex.js",
"lint:inline-styles": "node scripts/check-inline-styles.mjs",
"lint:tokens": "node scripts/check-design-tokens.mjs",
"lint:tokens": "node scripts/check-design-tokens.mjs --check=tokens",
"lint:tailwind-brackets": "node scripts/check-design-tokens.mjs --check=brackets",
"lint:tailwind-colors": "node scripts/check-tailwind-colors.mjs",
"check:css": "npm run build:css && git diff --exit-code -- css/tailwind.generated.css",
"test:visual": "npm run build:css && npx playwright test",
+197 -23
View File
@@ -15,11 +15,17 @@ const IGNORED_EXTENSIONS = new Set([".min.js", ".map"]);
const ALLOWED_FILES = new Set(["js/ui/components/RevertModal.js"]);
const LENGTH_PATTERN = /\d+(?:\.\d+)?(?:px|rem)\b/gim;
const MARKUP_EXTENSIONS = new Set([".html", ".md", ".mdx", ".markdown", ".njk", ".nunjucks"]);
const MARKUP_IGNORED_DIRECTORIES = new Set(["node_modules", ".git"]);
const CLASS_ATTRIBUTE_PATTERN = /class\s*=\s*(["'])(.*?)\1/gis;
const BRACKET_UTILITY_PATTERN = /\[[^\]]+\]/g;
const CHECK_MODES = new Set(["all", "tokens", "brackets"]);
function toPosix(relPath) {
return relPath.split(path.sep).join("/");
}
async function collectTargetFiles() {
async function collectScriptFiles() {
const results = new Set();
for (const target of TARGET_DIRECTORIES) {
@@ -35,6 +41,12 @@ async function collectTargetFiles() {
return [...results];
}
async function collectMarkupFiles() {
const results = new Set();
await walkMarkupDirectory(repoRoot, results);
return [...results];
}
async function walkDirectory(dir, results) {
let entries = [];
try {
@@ -63,6 +75,37 @@ async function walkDirectory(dir, results) {
}
}
async function walkMarkupDirectory(dir, results) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (error) {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (MARKUP_IGNORED_DIRECTORIES.has(entry.name)) {
continue;
}
const nested = path.join(dir, entry.name);
await walkMarkupDirectory(nested, results);
continue;
}
if (!entry.isFile()) {
continue;
}
const ext = path.extname(entry.name).toLowerCase();
if (!MARKUP_EXTENSIONS.has(ext)) {
continue;
}
results.add(path.join(dir, entry.name));
}
}
function buildLineBreaks(content) {
const breaks = [];
let index = content.indexOf("\n");
@@ -96,6 +139,22 @@ function getLineSnippet(content, index) {
return content.slice(start, end).trim();
}
function parseCheckMode(args) {
let mode = "all";
for (const arg of args) {
const match = arg.match(/^--check=(.+)$/);
if (match) {
mode = match[1];
}
}
if (!CHECK_MODES.has(mode)) {
throw new Error(`Unsupported --check value: ${mode}`);
}
return mode;
}
function shouldSkipMatch(content, index) {
const previousChar = index > 0 ? content[index - 1] : "";
if (previousChar === "[") {
@@ -128,37 +187,152 @@ function collectViolations(filePath, content) {
return violations;
}
async function main() {
const files = await collectTargetFiles();
function isAllowedBracketUtility(bracketValue) {
const inner = bracketValue.slice(1, -1).trim().toLowerCase();
if (!inner) {
return false;
}
if (inner.includes("var(--")) {
return true;
}
return false;
}
function extractUtilityFromClass(attributeValue, bracketIndex) {
let start = bracketIndex;
while (start > 0 && !/\s/.test(attributeValue[start - 1])) {
start -= 1;
}
let end = bracketIndex;
while (end < attributeValue.length && !/\s/.test(attributeValue[end])) {
end += 1;
}
return {
utility: attributeValue.slice(start, end),
startIndex: start,
};
}
function collectBracketViolations(filePath, content) {
const violations = [];
const lineBreaks = buildLineBreaks(content);
let classMatch;
CLASS_ATTRIBUTE_PATTERN.lastIndex = 0;
for (const filePath of files) {
const rel = toPosix(path.relative(repoRoot, filePath));
if (ALLOWED_FILES.has(rel)) {
while ((classMatch = CLASS_ATTRIBUTE_PATTERN.exec(content))) {
const attributeValue = classMatch[2];
if (!attributeValue) {
continue;
}
let content;
try {
content = await readFile(filePath, "utf8");
} catch (error) {
continue;
}
const fileViolations = collectViolations(filePath, content);
violations.push(...fileViolations);
}
if (violations.length > 0) {
console.error("Design token lint failed: raw measurements detected outside tokens.\n");
for (const violation of violations) {
console.error(
`${violation.file}:${violation.line} → ${violation.value}\n ${violation.snippet}`,
BRACKET_UTILITY_PATTERN.lastIndex = 0;
let bracketMatch;
while ((bracketMatch = BRACKET_UTILITY_PATTERN.exec(attributeValue))) {
const bracketValue = bracketMatch[0];
if (isAllowedBracketUtility(bracketValue)) {
continue;
}
const { utility, startIndex } = extractUtilityFromClass(
attributeValue,
bracketMatch.index,
);
const attributeOffset = classMatch[0].indexOf(attributeValue);
const absoluteIndex =
classMatch.index + attributeOffset + startIndex;
violations.push({
file: toPosix(path.relative(repoRoot, filePath)),
line: getLineNumber(lineBreaks, absoluteIndex),
value: utility || bracketValue,
snippet: getLineSnippet(content, absoluteIndex),
});
}
console.error("\nMove these measurements into css/tokens.css or use the metrics helper.");
process.exit(1);
}
process.exit(0);
return violations;
}
async function main() {
const mode = parseCheckMode(process.argv.slice(2));
const shouldCheckTokens = mode === "all" || mode === "tokens";
const shouldCheckBrackets = mode === "all" || mode === "brackets";
let exitCode = 0;
if (shouldCheckTokens) {
const files = await collectScriptFiles();
const violations = [];
for (const filePath of files) {
const rel = toPosix(path.relative(repoRoot, filePath));
if (ALLOWED_FILES.has(rel)) {
continue;
}
let content;
try {
content = await readFile(filePath, "utf8");
} catch (error) {
continue;
}
const fileViolations = collectViolations(filePath, content);
violations.push(...fileViolations);
}
if (violations.length > 0) {
console.error(
"Design token lint failed: raw measurements detected outside tokens.\n",
);
for (const violation of violations) {
console.error(
`${violation.file}:${violation.line} → ${violation.value}\n ${violation.snippet}`,
);
}
console.error(
"\nMove these measurements into css/tokens.css or use the metrics helper.",
);
exitCode = 1;
}
}
if (shouldCheckBrackets) {
const markupFiles = await collectMarkupFiles();
const bracketViolations = [];
for (const filePath of markupFiles) {
let content;
try {
content = await readFile(filePath, "utf8");
} catch (error) {
continue;
}
const fileViolations = collectBracketViolations(filePath, content);
bracketViolations.push(...fileViolations);
}
if (bracketViolations.length > 0) {
console.error(
"Tailwind bracket lint failed: arbitrary values must map to tokens.\n",
);
for (const violation of bracketViolations) {
console.error(
`${violation.file}:${violation.line} → ${violation.value}\n ${violation.snippet}`,
);
}
console.error(
"\nReplace these classes with tokenized utilities or sanctioned CSS variables.",
);
exitCode = 1;
}
}
process.exit(exitCode);
}
main().catch((error) => {