|
const stylelint = require("stylelint"); |
|
const { report, ruleMessages, validateOptions } = stylelint.utils; |
|
|
|
// ============================================ |
|
// Rule 1: No raw values — enforce var() usage |
|
// ============================================ |
|
|
|
const noRawValuesName = "semantic/no-raw-values"; |
|
const noRawValuesMessages = ruleMessages(noRawValuesName, { |
|
rejected: (prop, value) => |
|
`Raw value "${value}" in "${prop}". Use a CSS custom property var(--...) instead.`, |
|
}); |
|
|
|
const noRawValues = stylelint.createPlugin( |
|
noRawValuesName, |
|
function (primaryOption) { |
|
return function (root, result) { |
|
if (!validateOptions(result, noRawValuesName, { actual: primaryOption })) |
|
return; |
|
|
|
const enforced = [ |
|
"color", |
|
"background", |
|
"background-color", |
|
"border-color", |
|
"border", |
|
"outline-color", |
|
"margin", |
|
"margin-top", |
|
"margin-right", |
|
"margin-bottom", |
|
"margin-left", |
|
"padding", |
|
"padding-top", |
|
"padding-right", |
|
"padding-bottom", |
|
"padding-left", |
|
"gap", |
|
"row-gap", |
|
"column-gap", |
|
"font-size", |
|
"font-family", |
|
"border-radius", |
|
"width", |
|
"max-width", |
|
"min-width", |
|
"height", |
|
"max-height", |
|
"min-height", |
|
]; |
|
|
|
const rawColorPattern = |
|
/#[0-9a-fA-F]{3,8}\b|rgb\(|hsl\(|hwb\(|lab\(|lch\(/; |
|
|
|
root.walkDecls((decl) => { |
|
if (decl.parent?.type === "rule" && decl.parent.selector === ":root") |
|
return; |
|
if (!enforced.includes(decl.prop)) return; |
|
|
|
const hasRawColor = rawColorPattern.test(decl.value); |
|
const hasRawNumber = /\b\d+(\.\d+)?(px|rem|em|%)\b/.test(decl.value); |
|
const usesVar = /var\(--[\w-]+\)/.test(decl.value); |
|
|
|
// Allow "none", "0", "transparent", "inherit", "initial", "unset", "auto", "currentColor" |
|
const safeKeywords = |
|
/^(none|0|transparent|inherit|initial|unset|auto|currentColor|revert)$/i; |
|
if (safeKeywords.test(decl.value.trim())) return; |
|
|
|
// Allow calc() and other functions that use var() inside |
|
if (usesVar && !hasRawColor) return; |
|
|
|
if (hasRawColor || hasRawNumber) { |
|
report({ |
|
message: noRawValuesMessages.rejected(decl.prop, decl.value), |
|
node: decl, |
|
result, |
|
ruleName: noRawValuesName, |
|
}); |
|
} |
|
}); |
|
}; |
|
} |
|
); |
|
|
|
// ============================================ |
|
// Rule 2: No opacity for color dimming |
|
// ============================================ |
|
|
|
const noOpacityDimmingName = "semantic/no-opacity-dimming"; |
|
const noOpacityDimmingMessages = ruleMessages(noOpacityDimmingName, { |
|
rejectedOpacity: |
|
'Do not use "opacity" for text/color dimming. Define a -muted color token instead.', |
|
rejectedRgba: (value) => |
|
`Do not use rgba() alpha for color dimming in "${value}". Define a separate color token.`, |
|
}); |
|
|
|
const noOpacityDimming = stylelint.createPlugin( |
|
noOpacityDimmingName, |
|
function (primaryOption) { |
|
return function (root, result) { |
|
if ( |
|
!validateOptions(result, noOpacityDimmingName, { |
|
actual: primaryOption, |
|
}) |
|
) |
|
return; |
|
|
|
root.walkDecls((decl) => { |
|
if (decl.parent?.type === "rule" && decl.parent.selector === ":root") |
|
return; |
|
|
|
// Ban opacity property |
|
if (decl.prop === "opacity") { |
|
report({ |
|
message: noOpacityDimmingMessages.rejectedOpacity, |
|
node: decl, |
|
result, |
|
ruleName: noOpacityDimmingName, |
|
}); |
|
} |
|
|
|
// Ban rgba()/hsla() with alpha in color properties |
|
const colorProps = [ |
|
"color", |
|
"background-color", |
|
"background", |
|
"border-color", |
|
]; |
|
if (colorProps.includes(decl.prop)) { |
|
if (/rgba\(|hsla\(/.test(decl.value)) { |
|
report({ |
|
message: noOpacityDimmingMessages.rejectedRgba(decl.value), |
|
node: decl, |
|
result, |
|
ruleName: noOpacityDimmingName, |
|
}); |
|
} |
|
} |
|
}); |
|
}; |
|
} |
|
); |
|
|
|
// ============================================ |
|
// Rule 3: No unused custom properties |
|
// ============================================ |
|
|
|
const noUnusedTokensName = "semantic/no-unused-tokens"; |
|
const noUnusedTokensMessages = ruleMessages(noUnusedTokensName, { |
|
rejected: (token) => |
|
`Custom property "${token}" is defined but never used. Remove it.`, |
|
}); |
|
|
|
const noUnusedTokens = stylelint.createPlugin( |
|
noUnusedTokensName, |
|
function (primaryOption) { |
|
return function (root, result) { |
|
if ( |
|
!validateOptions(result, noUnusedTokensName, { actual: primaryOption }) |
|
) |
|
return; |
|
|
|
const defined = new Map(); // token name -> declaration node |
|
const used = new Set(); |
|
|
|
// Collect defined tokens |
|
root.walkDecls((decl) => { |
|
if (decl.prop.startsWith("--")) { |
|
defined.set(decl.prop, decl); |
|
// A token's value might reference other tokens |
|
const refs = decl.value.match(/var\((--[\w-]+)\)/g); |
|
if (refs) { |
|
refs.forEach((ref) => { |
|
const name = ref.match(/--([\w-]+)/)[0]; |
|
used.add("--" + name.replace(/^--/, "")); |
|
}); |
|
} |
|
} |
|
}); |
|
|
|
// Collect used tokens |
|
root.walkDecls((decl) => { |
|
if (decl.prop.startsWith("--")) return; |
|
const refs = decl.value.match(/var\((--[\w-]+)\)/g); |
|
if (refs) { |
|
refs.forEach((ref) => { |
|
const name = ref.match(/(--[\w-]+)/)[1]; |
|
used.add(name); |
|
}); |
|
} |
|
}); |
|
|
|
// Report unused |
|
for (const [token, node] of defined) { |
|
if (!used.has(token)) { |
|
report({ |
|
message: noUnusedTokensMessages.rejected(token), |
|
node, |
|
result, |
|
ruleName: noUnusedTokensName, |
|
}); |
|
} |
|
} |
|
}; |
|
} |
|
); |
|
|
|
// ============================================ |
|
// Rule 4: Color pair matching |
|
// ============================================ |
|
|
|
const colorPairMatchName = "semantic/color-pair-match"; |
|
const colorPairMatchMessages = ruleMessages(colorPairMatchName, { |
|
rejected: (fg, bg) => |
|
`Color "${fg}" does not match its background. On "${bg}", use "--color-on-${bg.replace("--color-", "")}" variants.`, |
|
}); |
|
|
|
const colorPairMatch = stylelint.createPlugin( |
|
colorPairMatchName, |
|
function (primaryOption) { |
|
return function (root, result) { |
|
if ( |
|
!validateOptions(result, colorPairMatchName, { actual: primaryOption }) |
|
) |
|
return; |
|
|
|
root.walkRules((rule) => { |
|
let bgToken = null; |
|
let colorDecls = []; |
|
|
|
rule.walkDecls((decl) => { |
|
if ( |
|
decl.prop === "background" || |
|
decl.prop === "background-color" |
|
) { |
|
const match = decl.value.match(/var\((--color-[\w-]+)\)/); |
|
if (match) bgToken = match[1]; |
|
} |
|
if (decl.prop === "color") { |
|
colorDecls.push(decl); |
|
} |
|
}); |
|
|
|
if (!bgToken || colorDecls.length === 0) return; |
|
|
|
// Only enforce pairing on actual surface tokens, not utility tokens like border-light |
|
const surfacePattern = /^--color-(bg|surface|primary|accent|secondary)/; |
|
if (!surfacePattern.test(bgToken)) return; |
|
|
|
// Extract the surface name: --color-surface -> surface, --color-bg-dark -> bg-dark |
|
const surface = bgToken.replace("--color-", ""); |
|
const expectedPrefix = `--color-on-${surface}`; |
|
|
|
colorDecls.forEach((decl) => { |
|
const match = decl.value.match(/var\((--color-[\w-]+)\)/); |
|
if (!match) return; |
|
const fgToken = match[1]; |
|
|
|
if (!fgToken.startsWith(expectedPrefix)) { |
|
report({ |
|
message: colorPairMatchMessages.rejected(fgToken, bgToken), |
|
node: decl, |
|
result, |
|
ruleName: colorPairMatchName, |
|
}); |
|
} |
|
}); |
|
}); |
|
}; |
|
} |
|
); |
|
|
|
module.exports = [noRawValues, noOpacityDimming, noUnusedTokens, colorPairMatch]; |