mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 18:02:27 +00:00
Add lint for Tailwind arbitrary values
This commit is contained in:
1 parent
478f32ec1b
commit
ed60f1b7af
3 files changed
+213
-26
No files matched your search
+13
-1
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user