mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
chore: add design tokens\css lint rules
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
// Shared constants for the design-tokens ESLint plugin.
|
||||
// Single source for property lists and regexes so each rule cooks from the
|
||||
// same vocabulary.
|
||||
|
||||
// =====================================================================
|
||||
// Properties that accept a COLOR.
|
||||
// =====================================================================
|
||||
export const COLOR_PROPS =
|
||||
/^(color|background|background-color|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
|
||||
|
||||
// =====================================================================
|
||||
// Properties that accept a SIZE (px/rem/em).
|
||||
// z-index is handled separately (it's an integer, not a length).
|
||||
// =====================================================================
|
||||
export const SIZE_PROPS =
|
||||
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|border-spacing)$/;
|
||||
|
||||
// =====================================================================
|
||||
// Properties that carry a DURATION (ms/s) — transitions/animations.
|
||||
// =====================================================================
|
||||
export const DURATION_PROPS =
|
||||
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
|
||||
|
||||
// =====================================================================
|
||||
// A hardcoded COLOR literal: hex / color functions / named colors.
|
||||
// Regex legend:
|
||||
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
|
||||
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
|
||||
// - names: common CSS color names on a word boundary
|
||||
// =====================================================================
|
||||
export const COLOR_LITERAL =
|
||||
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
|
||||
|
||||
// =====================================================================
|
||||
// A hardcoded SIZE literal (px/rem/em), fractional allowed: 0.5rem.
|
||||
// =====================================================================
|
||||
export const FORBIDDEN_SIZE_TOKEN = /\d+(?:\.\d+)?(?:px|rem|em)\b/g;
|
||||
|
||||
// =====================================================================
|
||||
// A hardcoded DURATION literal (ms/s): 200ms, 0.3s.
|
||||
// =====================================================================
|
||||
export const FORBIDDEN_DURATION_TOKEN = /\d+(?:\.\d+)?(?:ms|s)\b/g;
|
||||
|
||||
// =====================================================================
|
||||
// Token PREFIXES by category (tests run against the var() name, e.g.
|
||||
// --color-bg, --brand-main, --space-1, --z-header).
|
||||
// =====================================================================
|
||||
export const COLOR_TOKEN = /^--(?:color|brand)-/;
|
||||
export const SIZE_TOKEN = /^--(?:space|size|text|radius|bp|z)-/;
|
||||
export const DURATION_TOKEN = /^--(?:duration|ease|motion)-/;
|
||||
@@ -0,0 +1,81 @@
|
||||
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
|
||||
// A size property (padding, gap, font-size ...) must use a SIZE token
|
||||
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
|
||||
// (color, background, border-color ...) must use a COLOR token
|
||||
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
|
||||
// is a sign the wrong token is being reused.
|
||||
|
||||
import { COLOR_PROPS, COLOR_TOKEN, SIZE_PROPS, SIZE_TOKEN } from "./lists.js";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
|
||||
// Collect the token names referenced by var() in a value.
|
||||
const collectVars = (value) =>
|
||||
Array.from(value.matchAll(/var\(\s*(--[\w-]+)/g), (m) => m[1]);
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Ban using a color token in size properties and a size token in color properties; each property must use tokens of its own category.",
|
||||
category: "Design tokens",
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
categoryMismatch:
|
||||
"Token '{{token}}' in '{{prop}}' belongs to the {{category}} category; expected a {{expected}} token.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const root = getStyleRoot(context);
|
||||
if (!root) return {};
|
||||
const styleNodeLoc = getStyleNodeLoc(context);
|
||||
|
||||
return {
|
||||
"Program:exit"(programNode) {
|
||||
const report = (node, ruleId, data) => {
|
||||
if (!styleNodeLoc) return;
|
||||
const loc = styleNodeLoc(node);
|
||||
context.report({
|
||||
node: programNode,
|
||||
loc,
|
||||
ruleId,
|
||||
messageId: ruleId,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop ?? "";
|
||||
const value = decl.value ?? "";
|
||||
|
||||
const isSizeProp = SIZE_PROPS.test(prop);
|
||||
const isColorProp = COLOR_PROPS.test(prop);
|
||||
if (!isSizeProp && !isColorProp) return;
|
||||
|
||||
for (const token of collectVars(value)) {
|
||||
// A size property that holds a COLOR token.
|
||||
if (isSizeProp && COLOR_TOKEN.test(token)) {
|
||||
report(decl, "categoryMismatch", {
|
||||
token,
|
||||
prop,
|
||||
category: "color",
|
||||
expected: "size",
|
||||
});
|
||||
}
|
||||
// A color property that holds a SIZE token (z-radius/space/text...).
|
||||
if (isColorProp && SIZE_TOKEN.test(token)) {
|
||||
report(decl, "categoryMismatch", {
|
||||
token,
|
||||
prop,
|
||||
category: "size",
|
||||
expected: "color",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
|
||||
// Everything visual in a Svelte <style> block must come from design tokens
|
||||
// (var(--...)); direct literals are banned. color-mix() is banned too — its
|
||||
// result must be tokenized in the design CSS file.
|
||||
|
||||
import {
|
||||
COLOR_LITERAL,
|
||||
COLOR_PROPS,
|
||||
DURATION_PROPS,
|
||||
FORBIDDEN_DURATION_TOKEN,
|
||||
FORBIDDEN_SIZE_TOKEN,
|
||||
SIZE_PROPS,
|
||||
} from "./lists.js";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
|
||||
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
|
||||
// mistaken for literals.
|
||||
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Ban hardcoded colors, sizes, durations and z-index in Svelte <style> blocks; use CSS variables (design tokens).",
|
||||
category: "Design tokens",
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
hardcodedColor:
|
||||
"Hardcoded color '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
|
||||
hardcodedSize:
|
||||
"Hardcoded size '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
|
||||
hardcodedDuration:
|
||||
"Hardcoded duration '{{value}}' in '{{prop}}'. Use a var(--duration-...) token.",
|
||||
hardcodedZIndex:
|
||||
"Hardcoded z-index '{{value}}'. Use a var(--z-...) token.",
|
||||
hardcodedBreakpoint:
|
||||
"Hardcoded breakpoint '{{value}}' in @media. Use a var(--bp-...) token.",
|
||||
colorMix:
|
||||
"color-mix() in component ({{value}}). Tokenize the result in the design CSS file.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const root = getStyleRoot(context);
|
||||
if (!root) return {};
|
||||
const styleNodeLoc = getStyleNodeLoc(context);
|
||||
|
||||
return {
|
||||
"Program:exit"(programNode) {
|
||||
const report = (node, ruleId, data) => {
|
||||
if (!styleNodeLoc) return;
|
||||
const loc = styleNodeLoc(node);
|
||||
context.report({
|
||||
node: programNode,
|
||||
loc,
|
||||
ruleId,
|
||||
messageId: ruleId,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// 3) NO HARDCODED BREAKPOINTS IN @media
|
||||
// @media conditions must use --bp-* tokens, not raw lengths.
|
||||
// Regex: (max|min)-width followed by a NON-var(() number.
|
||||
// =====================================================================
|
||||
const BREAKPOINT_RE =
|
||||
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
|
||||
|
||||
// Sub-rule 6: color-mix() is banned anywhere in a component style.
|
||||
const COLOR_MIX_RE = /color-mix\s*\(/gi;
|
||||
|
||||
// --- @media breakpoints --- //
|
||||
root.walkAtRules("media", (atRule) => {
|
||||
const params = atRule.params ?? "";
|
||||
let m;
|
||||
BREAKPOINT_RE.lastIndex = 0;
|
||||
while ((m = BREAKPOINT_RE.exec(params)) !== null) {
|
||||
report(atRule, "hardcodedBreakpoint", { value: m[1] });
|
||||
}
|
||||
});
|
||||
|
||||
// --- declarations --- //
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop ?? "";
|
||||
const value = decl.value ?? "";
|
||||
|
||||
// Sub-rule 6: color-mix in any property.
|
||||
if (COLOR_MIX_RE.test(value)) {
|
||||
report(decl, "colorMix", { value: value.trim() });
|
||||
}
|
||||
|
||||
// Sub-rule 4: durations in transition/animation props.
|
||||
if (DURATION_PROPS.test(prop)) {
|
||||
const withoutVars = stripVars(value);
|
||||
const m = withoutVars.match(FORBIDDEN_DURATION_TOKEN);
|
||||
if (m) {
|
||||
report(decl, "hardcodedDuration", { prop, value: m[0] });
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rule 7: z-index must come from a var(--z-...).
|
||||
if (prop === "z-index") {
|
||||
const withoutVars = stripVars(value).trim();
|
||||
if (/^-?\d+$/.test(withoutVars)) {
|
||||
report(decl, "hardcodedZIndex", { value: withoutVars });
|
||||
}
|
||||
}
|
||||
|
||||
// Only inspect known layout properties from here on.
|
||||
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
|
||||
|
||||
// Sub-rule 2: sizes.
|
||||
// Fire only on a real remaining px/rem/em token — percentages
|
||||
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
|
||||
// Only legal absolute length is a single 1px border line.
|
||||
if (SIZE_PROPS.test(prop)) {
|
||||
const withoutVars = stripVars(value);
|
||||
const match = withoutVars.match(FORBIDDEN_SIZE_TOKEN);
|
||||
if (match) {
|
||||
const shown = match[0];
|
||||
if (shown !== "1px" && shown !== "0px") {
|
||||
report(decl, "hardcodedSize", { prop, value: shown });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rule 1: colors.
|
||||
// Remaining value must be only allowed keywords
|
||||
// (currentColor/transparent/inherit/none/0); otherwise a color
|
||||
// literal is reported.
|
||||
if (COLOR_PROPS.test(prop)) {
|
||||
const withoutVars = stripVars(value);
|
||||
const withoutKeywords = withoutVars
|
||||
.replace(
|
||||
/\b(?:currentcolor|transparent|inherit|none|initial|unset|revert)\b/gi,
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
if (withoutKeywords !== "" && withoutKeywords !== "0") {
|
||||
const match = value.match(COLOR_LITERAL);
|
||||
if (match) {
|
||||
report(decl, "hardcodedColor", {
|
||||
prop,
|
||||
value: match[0].trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// Rule: NO DESIGN PRIMITIVE IN COMPONENT TOKEN DEFINITIONS (variant A).
|
||||
// A custom property may be DEFINED inside a component's <style> block, but its
|
||||
// value must not introduce a design primitive: a raw color literal (hex/rgb/
|
||||
// oklch/named) or an absolute size (px/rem/em). This keeps the design surface
|
||||
// (colors/sizes) a single source of truth in preview.css, while still allowing
|
||||
// local DERIVED variables built from tokens: var(--...), calc(), unitless
|
||||
// ratios (--ratio: 1.5) — those are legitimate component-local state.
|
||||
|
||||
import { COLOR_LITERAL, FORBIDDEN_SIZE_TOKEN } from "./lists.js";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
|
||||
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
|
||||
// mistaken for literals.
|
||||
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Ban defining a custom property whose value introduces a raw color or size primitive in a Svelte <style> block; primitives belong in the design CSS file. Derived var()/calc()/unitless values are allowed.",
|
||||
category: "Design tokens",
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
tokenPrimitive:
|
||||
"Custom property '{{prop}}' defines a primitive '{{value}}' in a component. Move it to preview.css or derive it from tokens via var()/calc().",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const root = getStyleRoot(context);
|
||||
if (!root) return {};
|
||||
const styleNodeLoc = getStyleNodeLoc(context);
|
||||
|
||||
return {
|
||||
"Program:exit"(programNode) {
|
||||
const report = (node, ruleId, data) => {
|
||||
if (!styleNodeLoc) return;
|
||||
const loc = styleNodeLoc(node);
|
||||
context.report({
|
||||
node: programNode,
|
||||
loc,
|
||||
ruleId,
|
||||
messageId: ruleId,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// Only inspect custom property DEFINITIONS (--foo: value).
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop ?? "";
|
||||
if (!prop.startsWith("--")) return;
|
||||
|
||||
const value = decl.value ?? "";
|
||||
if (value.trim() === "") return;
|
||||
|
||||
const primitive = stripVars(value);
|
||||
|
||||
// A color literal in the definition → primitive.
|
||||
const colorHit = primitive.match(COLOR_LITERAL);
|
||||
if (colorHit) {
|
||||
report(decl, "tokenPrimitive", {
|
||||
prop,
|
||||
value: colorHit[0].trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// An absolute size (px/rem/em) in the definition → primitive.
|
||||
const sizeHit = primitive.match(FORBIDDEN_SIZE_TOKEN);
|
||||
if (sizeHit) {
|
||||
report(decl, "tokenPrimitive", {
|
||||
prop,
|
||||
value: sizeHit[0],
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
// Rule: NO UNDEFINED CSS-TOKEN USAGE IN COMPONENTS.
|
||||
// A var(--...) referenced in a Svelte <style> block must exist in the design
|
||||
// token file (src/preview.css) or be a local override defined in the same
|
||||
// component. Catches typos and drop-in tokens that were never added to the
|
||||
// "single source of truth".
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
|
||||
|
||||
// A custom property DEFINITION: "--x:" followed by a colon.
|
||||
const DEFINED_RE = /--[\w-]+(?=\s*:)/g;
|
||||
// A var() REFERENCE (primary argument only — fallbacks are optional overrides).
|
||||
const VAR_REF_RE = /var\(\s*(--[\w-]+)/g;
|
||||
|
||||
const definedCache = new Map();
|
||||
|
||||
function getDefinedTokens(cwd) {
|
||||
const file = resolve(cwd, "src/preview.css");
|
||||
if (!existsSync(file)) return null;
|
||||
if (definedCache.has(file)) return definedCache.get(file);
|
||||
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const tokens = new Set();
|
||||
for (const match of content.matchAll(DEFINED_RE)) {
|
||||
tokens.add(match[0]);
|
||||
}
|
||||
definedCache.set(file, tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow referencing a CSS variable that is not defined in preview.css (design token file) and not defined locally in the component.",
|
||||
category: "Design tokens",
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
undefinedToken:
|
||||
"CSS variable '{{token}}' is not defined in preview.css and not locally in this component.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const root = getStyleRoot(context);
|
||||
if (!root) return {};
|
||||
const styleNodeLoc = getStyleNodeLoc(context);
|
||||
|
||||
const globalTokens = getDefinedTokens(context.cwd);
|
||||
if (!globalTokens) return {};
|
||||
|
||||
return {
|
||||
"Program:exit"(programNode) {
|
||||
const report = (node, ruleId, data) => {
|
||||
if (!styleNodeLoc) return;
|
||||
const loc = styleNodeLoc(node);
|
||||
context.report({
|
||||
node: programNode,
|
||||
loc,
|
||||
ruleId,
|
||||
messageId: ruleId,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// Custom properties defined INSIDE this component count as defined.
|
||||
const localTokens = new Set();
|
||||
root.walkDecls((decl) => {
|
||||
if (decl.prop?.startsWith("--")) localTokens.add(decl.prop);
|
||||
});
|
||||
|
||||
const checkValue = (node, text) => {
|
||||
if (!text) return;
|
||||
let match;
|
||||
VAR_REF_RE.lastIndex = 0;
|
||||
while ((match = VAR_REF_RE.exec(text)) !== null) {
|
||||
const token = match[1];
|
||||
if (globalTokens.has(token) || localTokens.has(token)) continue;
|
||||
report(node, "undefinedToken", { token });
|
||||
}
|
||||
};
|
||||
|
||||
root.walkDecls((decl) => checkValue(decl, decl.value ?? ""));
|
||||
root.walkAtRules((atRule) => checkValue(atRule, atRule.params ?? ""));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
// Shared helpers to talk to the postcss AST of Svelte <style> blocks that
|
||||
// svelte-eslint-parser exposes. All rules use the same plumbing.
|
||||
|
||||
// Returns the postcss Root of the <style> block, or null when the file is not
|
||||
// a Svelte component (or its style failed to parse).
|
||||
export function getStyleRoot(context) {
|
||||
const parserServices = context.sourceCode?.parserServices;
|
||||
if (!parserServices || typeof parserServices.getStyleContext !== "function") {
|
||||
return null;
|
||||
}
|
||||
const styleContext = parserServices.getStyleContext();
|
||||
return styleContext?.status === "success" ? styleContext.sourceAst : null;
|
||||
}
|
||||
|
||||
// Returns a converter from a postcss node to an ESLint loc, or null if the
|
||||
// parser doesn't expose it.
|
||||
export function getStyleNodeLoc(context) {
|
||||
return context.sourceCode?.parserServices?.styleNodeLoc ?? null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Local ESLint plugin "design-tokens".
|
||||
*
|
||||
* Goal: a single source of truth for design. In Svelte components, hardcoded
|
||||
* colors and sizes are banned; everything must come from CSS variables (tokens
|
||||
* in preview.css). Rules inspect the postcss AST of Svelte <style> blocks
|
||||
* exposed by svelte-eslint-parser.
|
||||
*/
|
||||
import noCategoryMismatch from "./design-tokens/no-category-mismatch.js";
|
||||
import noHardcodedInSvelte from "./design-tokens/no-hardcoded-in-svelte.js";
|
||||
import noTokenDefinitionInSvelte from "./design-tokens/no-token-definition-in-svelte.js";
|
||||
import noUndefinedInSvelte from "./design-tokens/no-undefined-in-svelte.js";
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
name: "design-tokens",
|
||||
version: "0.2.0",
|
||||
},
|
||||
rules: {
|
||||
"no-hardcoded-in-svelte": noHardcodedInSvelte,
|
||||
"no-category-mismatch": noCategoryMismatch,
|
||||
"no-token-definition-in-svelte": noTokenDefinitionInSvelte,
|
||||
"no-undefined-in-svelte": noUndefinedInSvelte,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user